Merge branch 'feature/rss-manual-parsing' into develop

This commit is contained in:
Shinokuni 2020-10-05 22:48:08 +02:00
commit fb2e1de03b
90 changed files with 3104 additions and 1360 deletions

View File

@ -3,12 +3,10 @@ name: Android CI
on:
push:
branches:
- master
- develop
- '**'
pull_request:
branches:
- master
- develop
- '**'
jobs:
build:

View File

@ -43,26 +43,33 @@ dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':db')
// xpp3 has a conflict with kxml when running connectedCheck task
configurations {
all*.exclude group: 'xpp3', module: 'xpp3'
}
implementation "androidx.core:core-ktx:1.2.0"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
testImplementation 'junit:junit:4.13'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test:runner:1.3.0'
androidTestImplementation 'androidx.test:rules:1.3.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.8.1'
implementation 'com.squareup.retrofit2:retrofit:2.7.1'
implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:0.12'
implementation 'com.squareup.okhttp3:okhttp:4.8.1'
implementation('com.squareup.retrofit2:retrofit:2.7.1') {
exclude group: 'okhttp3', module: 'okhttp3'
}
implementation('com.squareup.retrofit2:converter-moshi:2.7.1') {
exclude group: 'moshi', module: 'moshi' // moshi converter uses moshi 1.8.0 which breaks codegen 1.9.2
}
implementation 'com.squareup.retrofit2:converter-simplexml:2.7.1'
implementation ('com.squareup.retrofit2:converter-simplexml:2.7.1') {
exclude module: 'stax'
exclude module: 'stax-api'
exclude module: 'xpp3'
}
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.7.1'
implementation 'com.squareup.moshi:moshi:1.9.2'

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US">
<id>tag:github.com,2008:/readrops/Readrops/commits/develop</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commits/develop"/>
<link type="application/atom+xml" rel="self" href="https://github.com/readrops/Readrops/commits/develop.atom"/>
<title>Recent Commits to Readrops:develop</title>
<updated>2020-09-06T21:09:59Z</updated>
<subtitle>Here is a subtitle</subtitle>
</feed>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en-US">
<id>tag:github.com,2008:/readrops/Readrops/commits/develop</id>
<title>Recent Commits to Readrops:develop</title>
<updated>2020-09-06T21:09:59Z</updated>
<subtitle>Here is a subtitle</subtitle>
</feed>

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<id>tag:github.com,2008:/readrops/Readrops/commits/develop</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commits/develop"/>
<link type="application/atom+xml" rel="self" href="https://github.com/readrops/Readrops/commits/develop.atom"/>
<title>Recent Commits to Readrops:develop</title>
<updated>2020-09-06T21:09:59Z</updated>
<entry>
<id>tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac"/>
<title>Add an option to open item url in custom tab</title>
<updated>2020-09-06T21:09:59Z</updated>
<media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/18555673?s=30&amp;u=c56b216e8d128d0ec217062feeace9faca4dc893&amp;v=4"/>
<author>
<name>Shinokuni</name>
<uri>https://github.com/Shinokuni</uri>
</author>
<summary>Summary</summary>
<content type="html">
&lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Add an option to open item url in custom tab&lt;/pre&gt;
</content>
</entry>
<entry>
<id>tag:github.com,2008:Grit::Commit/e0945823eecf269e5beea646ac5d7e630e08afbf</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commit/e0945823eecf269e5beea646ac5d7e630e08afbf"/>
<title>
Use gradle parallel builds
</title>
<updated>2020-09-05T13:28:23Z</updated>
<media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/18555673?s=30&amp;u=c56b216e8d128d0ec217062feeace9faca4dc893&amp;v=4"/>
<author>
<name>Shinokuni</name>
<uri>https://github.com/Shinokuni</uri>
</author>
<content type="html">
&lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Use gradle parallel builds&lt;/pre&gt;
</content>
</entry>
<entry>
<id>tag:github.com,2008:Grit::Commit/85fcf03e64d8b482e4d2af8c2bcd1509d946944f</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commit/85fcf03e64d8b482e4d2af8c2bcd1509d946944f"/>
<title>
Use clear text mode for the feed url text input in AddFeedActivity
</title>
<updated>2020-09-05T12:23:48Z</updated>
<media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/18555673?s=30&amp;u=c56b216e8d128d0ec217062feeace9faca4dc893&amp;v=4"/>
<author>
<name>Shinokuni</name>
<uri>https://github.com/Shinokuni</uri>
</author>
<content type="html">
&lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Use clear text mode for the feed url text input in AddFeedActivity&lt;/pre&gt;
</content>
</entry>
<entry>
<id>tag:github.com,2008:Grit::Commit/d59e38ee9d11da186131b602425231eff0896956</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commit/d59e38ee9d11da186131b602425231eff0896956"/>
<title>
Use project level okhttp client with glide
</title>
<updated>2020-09-05T12:05:16Z</updated>
<media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/18555673?s=30&amp;u=c56b216e8d128d0ec217062feeace9faca4dc893&amp;v=4"/>
<author>
<name>Shinokuni</name>
<uri>https://github.com/Shinokuni</uri>
</author>
<content type="html">
&lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Use project level okhttp client with glide&lt;/pre&gt;
</content>
</entry>
</feed>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<id>tag:github.com,2008:/readrops/Readrops/commits/develop</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commits/develop"/>
<link type="application/atom+xml" rel="self" href="https://github.com/readrops/Readrops/commits/develop.atom"/>
<title>Recent Commits to Readrops:develop</title>
<updated>2020-09-06T21:09:59Z</updated>
<entry>
<id>tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac</id>
<title>Add an option to open item url in custom tab</title>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac"/>
<media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/18555673?s=30&amp;u=c56b216e8d128d0ec217062feeace9faca4dc893&amp;v=4"/>
<author>
<name>Shinokuni</name>
<uri>https://github.com/Shinokuni</uri>
</author>
<summary>Summary</summary>
<content type="html">
&lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Add an option to open item url in custom tab&lt;/pre&gt;
</content>
</entry>
</feed>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<id>tag:github.com,2008:/readrops/Readrops/commits/develop</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commits/develop"/>
<link type="application/atom+xml" rel="self" href="https://github.com/readrops/Readrops/commits/develop.atom"/>
<title>Recent Commits to Readrops:develop</title>
<updated>2020-09-06T21:09:59Z</updated>
<entry>
<id>tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac</id>
<title>Add an option to open item url in custom tab</title>
<updated>2020-09-06T21:09:59Z</updated>
<media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/18555673?s=30&amp;u=c56b216e8d128d0ec217062feeace9faca4dc893&amp;v=4"/>
<author>
<name>Shinokuni</name>
<uri>https://github.com/Shinokuni</uri>
</author>
<summary>Summary</summary>
<content type="html">
&lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Add an option to open item url in custom tab&lt;/pre&gt;
</content>
</entry>
</feed>

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" xml:lang="en-US">
<id>tag:github.com,2008:/readrops/Readrops/commits/develop</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commits/develop"/>
<link type="application/atom+xml" rel="self" href="https://github.com/readrops/Readrops/commits/develop.atom"/>
<title>Recent Commits to Readrops:develop</title>
<updated>2020-09-06T21:09:59Z</updated>
<entry>
<id>tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac</id>
<link type="text/html" rel="alternate" href="https://github.com/readrops/Readrops/commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac"/>
<updated>2020-09-06T21:09:59Z</updated>
<media:thumbnail height="30" width="30" url="https://avatars2.githubusercontent.com/u/18555673?s=30&amp;u=c56b216e8d128d0ec217062feeace9faca4dc893&amp;v=4"/>
<author>
<name>Shinokuni</name>
<uri>https://github.com/Shinokuni</uri>
</author>
<summary>Summary</summary>
<content type="html">
&lt;pre style=&#39;white-space:pre-wrap;width:81ex&#39;&gt;Add an option to open item url in custom tab&lt;/pre&gt;
</content>
</entry>
</feed>

View File

@ -0,0 +1,86 @@
{
"version": "https://jsonfeed.org/version/1",
"title": "News from Flying Meat",
"home_page_url": "http://flyingmeat.com/blog/",
"feed_url": "http://flyingmeat.com/blog/feed.json",
"description": "News from your friends at Flying Meat.",
"author": {
"name": "Gus Mueller"
},
"items": [
{
"id": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html",
"title": "Acorn and 10.13",
"content_html": "<p>Happy Mac OS High Sierra release day everyone.</p>\n<p>I&#39;m happy to say that there are no known issues with <a href=\"https://flyingmeat.com/acorn/\">Acorn</a> 6.0.3 or Acorn 5.6.6 when running on Mac OS 10.13 High Sierra. In fact, you might even notice that some things are actually faster and it can now open HEIF images. How awesome is that?</p>\n<p>I&#39;m also working on some 10.13 goodies for Acorn 6 folks later this year. I can&#39;t wait to share that with you, but you&#39;ll have to wait just a little bit.</p>\n",
"date_published": "2017-09-25T14:27:27-07:00",
"url": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html",
"author": {
"url": "this is an url",
"name": "Author 1"
}
},
{
"id": "http://flyingmeat.com/blog/archives/2018/2/acorn_6.1_is_out.html",
"title": "Acorn 6.1 Is Out",
"content_html": "<p><a href=\"https://flyingmeat.com/acorn/\">Acorn 6.1 has been released</a>.</p>\n<p>You can <a href=\"http://shapeof.com/archives/2018/2/acorn_6.1_is_out.html\">read a longer post about it</a> over on Gus&#39;s blog, but the short of it is: Better, faster, smoother, stronger. And now with Metal 2 support.</p>\n",
"date_published": "2018-02-16T09:59:11-08:00",
"url": "http://flyingmeat.com/blog/archives/2018/2/acorn_6.1_is_out.html"
},
{
"id": "http://flyingmeat.com/blog/archives/2018/6/a_pair_of_updates.html",
"title": "A Pair of Updates",
"content_html": "<p>Happy summer solstice everybody! (at least for folks in the northern hemisphere, and for folks in the south… sorry. It&#39;s going to start getting brighter for you though).</p>\n<p>Today I&#39;ve got a pair of minor app updates to annouce for you.</p>\n<p>First up is <a href=\"https://flyingmeat.com/acorn/\">Acorn 6.1.3</a>, which <a href=\"https://flyingmeat.com/acorn/releasenotes.html\">fixes a number of bugs</a> including one that stemmed from trying to use QuickLook on a file that was created with Acorn 1.0. For the one or two of you that this was affecting, hurray!</p>\n<p>Next up is <a href=\"https://flyingmeat.com/retrobatch/\">Retrobatch</a>, which also <a href=\"https://flyingmeat.com/retrobatch/releasenotes.html\">includes some bug fixes</a>, the beginnings of Voice Over support, performance improvements, and more.</p>\n<p>What&#39;s next for these apps? Work on Acorn 6.2 will begin shortly, as will Retrobatch 1.1. WWDC introduced some great new APIs that I want to take advantage of (cool new machine learning things), so that&#39;ll be a focus- as well as Dark Mode for Acorn and one other major thing I&#39;ve got planned. Retrobatch will probably also get the Dark Mode treatment, but not until I&#39;ve done it for Acorn first.</p>\n<p>So it&#39;s going to be a busy summer, but I&#39;m looking forward to it.</p>\n",
"date_published": "2018-06-21T10:18:46-07:00",
"url": "http://flyingmeat.com/blog/archives/2018/6/a_pair_of_updates.html"
},
{
"id": "http://flyingmeat.com/blog/archives/2018/9/retrobatch_1.1_is_out.html",
"title": "Retrobatch 1.1 Is Out",
"content_html": "<p>Here&#39;s something new for your lazy <strike>August</strike> September* morning: <a href=\"https://flyingmeat.com/retrobatch/\">Retrobatch 1.1 is out</a>.</p>\n<p>What&#39;s new and awesome? Well, <a href=\"https://flyingmeat.com/retrobatch/\">Retrobatch</a> now has some great scripting goodness in the form of a new Automator action which will run a workflow for you (and create Automator droplets), a new JavaScript node*, and the ability to run Retrobatch workflows from the terminal.</p>\n<p>We&#39;ve added a handful of new nodes such as Dither, Auto Enhance, Instant Alpha, and Color Posterize. New options to existing nodes have also shown up, such as &quot;Only scale smaller&quot; for the Scale node.</p>\n<p>And an interesting idea that I&#39;ve had folks ask about a number of times- it&#39;s now possible to run an image through a machine learning classifier, and then have the classification written to metadata such as the image title, or keywords. This was done by adding token support to the Set Specific Metadata node. This also means you can use other tokens such as the Current Year in metadata fields. Awesome? We think so.</p>\n<p>The <a href=\"https://flyingmeat.com/retrobatch/releasenotes.html\">full release notes are available</a>, and if you have ideas or questions- make sure to <a href=\"https://forums.flyingmeat.com/\">poke around on the forums</a> or write us: <a href=\"mailto:support@flyingmeat.com\">support@flyingmeat.com</a>. We&#39;ve got lots of ideas for future releases, but if you&#39;d like something specific in there make sure to let us know.</p>\n<br/>\n\n<div style=\"color:#666\">\n* Whoa, it's September already?<br/><br/>\n\n<p>**I&#39;m calling the JavaScript node a &quot;preview&quot;. It works very well, but I&#39;m not 100% sold on the API that I&#39;ve provided to folks. So this is a disclaimer that it might change a little bit in the future.</div></p>\n",
"date_published": "2018-09-07T09:43:10-07:00",
"url": "http://flyingmeat.com/blog/archives/2018/9/retrobatch_1.1_is_out.html"
},
{
"id": "http://flyingmeat.com/blog/archives/2018/9/acorn_6.2_with_mojave_dark_mode_is_out.html",
"title": "Acorn 6.2 With Mojave Dark Mode Is Out",
"content_html": "<p>On Monday I flipped some switches on the FM servers and <a href=\"https://flyingmeat.com/acorn/\">Acorn 6.2</a> was released to the universe. You might also remember that Monday a little known operating system from Apple was updated, which includes a neat new feature known as Dark Mode.</p>\n<center><img src=\"https://shapeof.com/archives/2018/media/acorn62.jpeg\" width=\"800\" style=\"\" /></center>\n\n<p>I think Acorn looks pretty good in Dark Aqua, especially the icon refresh from <a href=\"http://www.matthewskiles.com/\">Matthew Skiles</a>.</p>\n<p>To celebrate the new release, we&#39;ve put <a href=\"https://flyingmeat.com/store/\">Acorn on sale for 50% off</a>. So go grab it at the insanely low price of $14.99. If you haven&#39;t already upgraded from previous versions of Acorn, now is a good time to do so.</p>\n<p>We&#39;ve also packed a bunch of little changes, bug fixes, and compatibility with Mojave in there. And of course, there&#39;s more to come in the future as always.</p>\n",
"date_published": "2018-09-27T14:37:21-07:00",
"url": "http://flyingmeat.com/blog/archives/2018/9/acorn_6.2_with_mojave_dark_mode_is_out.html"
},
{
"id": "http://flyingmeat.com/blog/archives/2019/1/acorn_6.3_is_out.html",
"title": "Acorn 6.3 Is Out",
"content_html": "<p><a href=\"https://flyingmeat.com/acorn/\">Acorn 6.3 is available</a>, and the full <a href=\"https://flyingmeat.com/acorn/releasenotes.html\">release notes</a> are up as well.</p>\n<p>Here&#39;s what I think is awesome in this release:</p>\n<p><strong>Portrait Mask Support</strong>. If you have an iPhone running iOS 12 (and can take Portrait photos), Acorn will now detect the Portrait Matte from those images and turn it into a layer mask. The Portrait Matte is the image data which enables blurring in the background, or other fancy camera tricks. This means you can use this matte to erase and add fancy backgrounds or custom blurs for your image, all within Acorn.</p>\n<p><strong>Other Mask Features</strong>. You can now drag and drop masks from the layers list into another layer, or copy it out as a new layer. When exporting layers you now have an option to apply the mask on export, or just write it as an additional image along with everything else. There are a number of new shortcuts when dealing with layer masks as well.</p>\n<p><strong>Brush Stuff</strong>. If you&#39;re running MacOS 10.13 or later, you get a performance boost when brushing (painting, smudging, cloning, etc…). This is especially noticeable when brusing on deep color images.</p>\n<p>I&#39;ve also added options to the brush palette for adjusting flow, softness and blending. In addition to all this, there&#39;s a bunch of new brushes under the &quot;Basic Round&quot; category which are designed for the new brush engine.</p>\n<p><strong>Other Stuff</strong>. There&#39;s other good things including improved PDF export, various MacOS Mojave UI fixes, additional speed improvements with with deep images, and more. And as always, it&#39;s a free upgrade for anyone who has already purchased Acorn 6.</p>\n",
"date_published": "2019-01-09T13:00:07-08:00",
"url": "http://flyingmeat.com/blog/archives/2019/1/acorn_6.3_is_out.html"
},
{
"id": "http://flyingmeat.com/blog/archives/2019/4/retrobatch_1.2_released.html",
"title": "Retrobatch 1.2 Released",
"content_html": "<p>We&#39;re happy to announce that Retrobatch 1.2 has now been released, which is a free update for all owners of Retrobatch. Highlights of this release include:</p>\n<ul>\n<li><p><strong>Create animated GIF and PNG</strong> images with the Animated Image node. When using Retrobatch you can load in a folder of images and produce an optimized animated image with options for setting the frame rate, format, as well as letting the image loop or not.</p>\n</li>\n<li><p><strong>New nodes</strong> including &quot;Round Corner&quot;, &quot;Image Grid&quot;, and &quot;Limit&quot;. We&#39;ve also added improvements to the Write node allowing you to write back to the original processed image.</p>\n</li>\n<li><p><strong>Droplet support</strong> (Retrobatch Pro). Turn your workflow into an an application which you can drag and drop images onto. The droplet can work anywhere an application normally would, even in the Dock.</p>\n</li>\n<li><p><strong>Write Plug-Ins using JavaScript</strong> (Retrobatch Pro). Using the combined power of JavaScript and the native to MacOS Cocoa APIs, you can <a href=\"https://flyingmeat.com/retrobatch/jsapi/\">make and distribute</a> new plugins for Retrobatch. Got an idea for a plug-in and you want to use Core Image to make it? Or maybe you want to use Core Graphics to add some funky text to your images? Now you can do this with JavaScript and Cocoa.</p>\n</li>\n</ul>\n<p>The <a href=\"https://flyingmeat.com/retrobatch/releasenotes.html\">full release notes are available</a>, as well as information on bug fixes we delivered in this update.</p>\n<p>As always, we&#39;re <a href=\"https://flyingmeat.com/retrobatch/releasenotes.html\">always listening for feedback</a> and feature requests. And don&#39;t forget to head over to the <a href=\"http://forums.flyingmeat.com/c/retrobatch\">Retrobatch community formus</a> to chat with us and other Retrobatch users. </p>\n",
"date_published": "2019-04-01T13:38:21-07:00",
"url": "http://flyingmeat.com/blog/archives/2019/4/retrobatch_1.2_released.html"
},
{
"id": "http://flyingmeat.com/blog/archives/2019/10/catalina_ready.html",
"title": "Catalina Ready",
"content_html": "<p>MacOS 10.15 Catalina was just released, and we&#39;re happy to let you know that both <a href=\"https://flyingmeat.com/acorn/\">Acorn 6.5.1</a> and <a href=\"https://flyingmeat.com/retrobatch/\">Retrobatch 1.2</a> are compatible with it.</p>\n<p>And to celebrate the release of Catalina, we&#39;re <a href=\"https://flyingmeat.com/store/\">discounting Acorn by 50% for a limited time</a>. So if you haven&#39;t upgraded yet, now is a good time.</p>\n",
"date_published": "2019-10-07T10:48:03-07:00",
"url": "http://flyingmeat.com/blog/archives/2019/10/catalina_ready.html"
},
{
"id": "http://flyingmeat.com/blog/archives/2020/3/retrobatch_1.4_is_out.html",
"title": "Retrobatch 1.4 Is Out",
"content_html": "<p>I&#39;ve just typed the magic commands* and let the servers do their thing and now <a href=\"https://flyingmeat.com/retrobatch/\">Retrobatch 1.4</a> is loose on the world.</p>\n<p>There&#39;s a couple of interesting new features in this update I&#39;d like to call out. First up is JavaScript expressions in Retrobatch Pro. Various nodes in Retrobatch which allow you to set the size or length of a value (such as the Crop, Border, Gradient, Adjust Margin nodes) now have an option of running a little snippet of JavaScript code to figure out the value. This is a super powerful feature, which you can read about in our <a href=\"https://flyingmeat.com/retrobatch/jsapi-1/jsexpressions/\">JavaScript Expressions documentation</a></p>\n<p>Let&#39;s say you have some images of varying sizes, which are all at 480 x 380 or smaller, and you want them to expand to meet that size. But- you only want it to grow evenly on either side of the image, but you want to keep a baseline so only transparent area is added to the top of the image, and the bottom stays in the same spot. This little picture of the new Adjust Margins node shows how this can be done:</p>\n<center><img src=\"https://flyingmeat.com/retrobatch/jsapi-1/images/javascript_expression_fields_shot.png\" width=\"444\" style=\"border: solid 1px #777;\" /></center>\n\n<p>Yes, this is an oddball (and very real) case- but there&#39;s a billion of these little oddball cases out there. With the new JavaScript expressions support, these small but hard to do scenarios are now super easy.</p>\n<p>And yes, all of the JavaScript support in Retrobatch now sits atop <a href=\"https://github.com/ccgus/fmjs\">FMJS</a>, which any developer can use to build similar support into their apps.</p>\n<p>What else is new?</p>\n<p>File numbers with leading zeros for the Write node. You can add (and it&#39;s case sensitive) $FileNumber04$ in the File name: field of the Write node to have the file number of your image written out as part of the name, with a padding of up to 4 zeros. If you&#39;d like to pad that number to 6, you would enter $FileNumber06$, and so on.</p>\n<p>The Mask to Alpha node got a new &quot;invert colors&quot; option. Normally Mask to Alpha will convert the black areas of your image to transparent, and the white to opaque (with gray somewhere inbetween). With the new Invert Colors option, Mask to Alpha will now convert the white areas of your image to transparent, and keep the black opaque. This is great if you are scanning in line drawings from your own artwork, and want to make the backgrounds transparent.</p>\n<p>This request comes up a lot in <a href=\"https://flyingmeat.com/acorn/\">Acorn</a> as well. Previously you&#39;d have to add an Invert Colors node (or filter for Acorn), then the Mask to Alpha, and then Invert Colors again. Now it&#39;s just a checkbox in Mask to Alpha, which is super easy. I&#39;ve also added an update to the same filter in Acorn for the next release. You can grab a preview of it <a href=\"http://flyingmeat.com/download/latest/\">from here</a>.</p>\n<p>And finally for my short list, you can now make a droplet which doesn&#39;t take any files. Why is this useful? Well, imagine you have a workflow that reads an image from the clipboard, resizes it to a specific width, and then writes it back to the clipboard. Now you can make a little droplet to do just this. Just a double click from the Finder (or a single click from the Dock) and your workflow is run.</p>\n<p>The full release notes for Retrobatch 1.4 are <a href=\"https://flyingmeat.com/retrobatch/releasenotes.html\">available in the usual place</a>.</p>\n<p>* <code>./bin/otbuild.sh -e 1.4</code></p>\n",
"date_published": "2020-03-31T14:37:15-07:00",
"url": "http://flyingmeat.com/blog/archives/2020/3/retrobatch_1.4_is_out.html"
},
{
"id": "http://flyingmeat.com/blog/archives/2020/5/acorn_6.6_released.html",
"title": "Acorn 6.6 is out with new Shape Processors and more",
"content_html": "<p><a href=\"https://flyingmeat.com/acorn/\">Acorn 6.6</a> is out. You can update to this release via the <a href=\"https://flyingmeat.com/acorn/appstore/\">App Store</a> as or the Acorn ▸ Check for Updates… menu if you bought it directly from us.</p>\n<p>Originally this was going to be a bug fix release but I kept on adding useful things and it snowballed into a feature release. As usual, <a href=\"https://flyingmeat.com/acorn/releasenotes.html\">the full release notes</a> have all the details about what was updated.</p>\n<p>The main new features are with the <a href=\"https://flyingmeat.com/acorn/docs/shape_processor.html\">Shape Processor</a>. If you&#39;re not already familiar with the shape processor, it&#39;s a neat ability Acorn has to take shapes on vector layers and pipe them through a series of actions, similar to how Automator or Acorn&#39;s bitmap filters work. Only instead of working on pixels, the processors will alter the shapes by scaling them or moving them around, or changing colors or blend modes. There&#39;s even a processor which will generate shapes for you- so if you want your canvas to fill up with hundreds of stars, you can do that.</p>\n<p>Acorn 6.6 adds new processors which let you set the stroke, fill, and blend mode of your processed shapes. You can now also flip your shapes and even shift colors. </p>\n<img src=\"https://shapeof.com/archives/2020/5/proc_shape.png\" width=\"660\" height=\"510\" />\n\n<p>Chaining these processors together can get you some neat looking images. You can make interesting desktop backgrounds, as well as textures for your photos. Or if you just need a bunch of hexagons arranged in a circle, that&#39;s just two processors stacked together.</p>\n<p>Have you made something interesting with the Shape Processor? I&#39;d love to see it either via Twitter (I&#39;m <a href=\"https://twitter.com/ccgus/\">@ccgus</a>) or via <a href=\"mailto:support@flyingmeat.com\">email</a>.</p>\n<p>There are of course the usual bug fixes and other <a href=\"https://flyingmeat.com/acorn/releasenotes.html\">minor details</a>. And if you don&#39;t already have Acorn, a <a href=\"https://flyingmeat.com/acorn/\">no-strings attached free trial</a> is available on our website. Try it out, and we&#39;re always looking to hear from you about feature requests, thoughts, and anything else.</p>\n",
"date_published": "2020-05-28T12:17:57-07:00",
"url": "http://flyingmeat.com/blog/archives/2020/5/acorn_6.6_released.html"
}
]
}

View File

@ -0,0 +1,10 @@
{
"items": [
{
"id": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html",
"title": "Acorn and 10.13",
"content_html": "<p>Happy Mac OS High Sierra release day everyone.</p>\n<p>I&#39;m happy to say that there are no known issues with <a href=\"https://flyingmeat.com/acorn/\">Acorn</a> 6.0.3 or Acorn 5.6.6 when running on Mac OS 10.13 High Sierra. In fact, you might even notice that some things are actually faster and it can now open HEIF images. How awesome is that?</p>\n<p>I&#39;m also working on some 10.13 goodies for Acorn 6 folks later this year. I can&#39;t wait to share that with you, but you&#39;ll have to wait just a little bit.</p>\n",
"url": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html"
}
]
}

View File

@ -0,0 +1,10 @@
{
"items": [
{
"id": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html",
"title": "Acorn and 10.13",
"content_html": "<p>Happy Mac OS High Sierra release day everyone.</p>\n<p>I&#39;m happy to say that there are no known issues with <a href=\"https://flyingmeat.com/acorn/\">Acorn</a> 6.0.3 or Acorn 5.6.6 when running on Mac OS 10.13 High Sierra. In fact, you might even notice that some things are actually faster and it can now open HEIF images. How awesome is that?</p>\n<p>I&#39;m also working on some 10.13 goodies for Acorn 6 folks later this year. I can&#39;t wait to share that with you, but you&#39;ll have to wait just a little bit.</p>\n",
"date_published": "2017-09-25T14:27:27-07:00"
}
]
}

View File

@ -0,0 +1,10 @@
{
"items": [
{
"id": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html",
"content_html": "<p>Happy Mac OS High Sierra release day everyone.</p>\n<p>I&#39;m happy to say that there are no known issues with <a href=\"https://flyingmeat.com/acorn/\">Acorn</a> 6.0.3 or Acorn 5.6.6 when running on Mac OS 10.13 High Sierra. In fact, you might even notice that some things are actually faster and it can now open HEIF images. How awesome is that?</p>\n<p>I&#39;m also working on some 10.13 goodies for Acorn 6 folks later this year. I can&#39;t wait to share that with you, but you&#39;ll have to wait just a little bit.</p>\n",
"date_published": "2017-09-25T14:27:27-07:00",
"url": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html"
}
]
}

View File

@ -0,0 +1,40 @@
{
"items": [
{
"id": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html",
"title": "Acorn and 10.13",
"summary": "This is a summary",
"content_html": "content_html",
"content_text": "content_text",
"date_published": "2017-09-25T14:27:27-07:00",
"url": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html",
"image": "https://image.com",
"authors": [
{
"url": "url 1",
"name": "Author 1"
},
{
"url": "url 2",
"name": ""
},
{
"url": "url 3",
"name": "Author 3"
},
{
"url": "url 4",
"name": "Author 4"
},
{
"url": "url 5",
"name": "Author 5"
},
{
"url": "url 6",
"name": "Author 6"
}
]
}
]
}

View File

@ -0,0 +1,269 @@
<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:syn="http://purl.org/rss/1.0/modules/syndication/"
xmlns="http://purl.org/rss/1.0/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel rdf:about="https://slashdot.org/">
<title>Slashdot</title>
<link>https://slashdot.org/</link>
<description>News for nerds, stuff that matters</description>
<dc:language>en-us</dc:language>
<dc:rights>Copyright 1997-2016, SlashdotMedia. All Rights Reserved.</dc:rights>
<dc:date>2020-09-23T16:20:20+00:00</dc:date>
<dc:publisher>Dice</dc:publisher>
<dc:creator>help@slashdot.org</dc:creator>
<dc:subject>Technology</dc:subject>
<syn:updateBase>1970-01-01T00:00+00:00</syn:updateBase>
<syn:updateFrequency>1</syn:updateFrequency>
<syn:updatePeriod>hourly</syn:updatePeriod>
<items>
<rdf:Seq>
<rdf:li
rdf:resource="https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/1528219/firefox-usage-is-down-85-despite-mozillas-top-exec-pay-going-up-400?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/1451213/climate-disruption-is-now-locked-in-the-next-moves-will-be-crucial?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/1420240/a-new-york-clock-that-told-time-now-tells-the-time-remaining?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/0057256/jeff-bezos-is-opening-his-first-tuition-free-bezos-academy-preschool-where-each-child-will-be-the-customer?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/0050249/google-is-pulling-the-plug-on-paid-chrome-extensions-over-the-next-year?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://entertainment.slashdot.org/story/20/09/22/2147243/old-tv-caused-village-broadband-outages-for-18-months?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://mobile.slashdot.org/story/20/09/22/2316256/t-mobile-amassed-unprecedented-concentration-of-spectrum-att-complains?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://yro.slashdot.org/story/20/09/22/2030223/dark-web-drugs-raid-leads-to-179-arrests?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://hardware.slashdot.org/story/20/09/22/2055203/the-fairphone-3-is-a-repairable-dream-that-takes-beautiful-photos?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://tech.slashdot.org/story/20/09/23/0039222/tesla-unveils-model-s-plaid-520-miles-200-mph-and-0-60-mph-in-less-than-2s?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://apple.slashdot.org/story/20/09/22/211222/apple-ceo-impressed-by-remote-work-sees-permanent-changes?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://hardware.slashdot.org/story/20/09/22/236228/tesla-announces-tabless-battery-cells-that-will-improve-range-of-its-electric-cars?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://linux.slashdot.org/story/20/09/22/2243209/linux-journal-is-back?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://hardware.slashdot.org/story/20/09/22/2026238/shell-reportedly-to-slash-oil-and-gas-production-costs-to-focus-more-on-renewables?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
</rdf:Seq>
</items>
<image rdf:resource="https://a.fsdn.com/sd/topics/topicslashdot.gif" />
<textinput rdf:resource="https://slashdot.org/search.pl" />
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" href="http://rss.slashdot.org/slashdot/slashdotMain"
rel="self" type="application/rdf+xml" />
<feedburner:info xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0"
uri="slashdot/slashdotmain" />
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" href="http://pubsubhubbub.appspot.com/"
rel="hub" />
</channel>
<image rdf:about="https://a.fsdn.com/sd/topics/topicslashdot.gif">
<title>Slashdot</title>
<url>https://a.fsdn.com/sd/topics/topicslashdot.gif</url>
<link>https://slashdot.org/</link>
</image>
<item
rdf:about="https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed">
<title>Google Expands its Flutter Development Kit To Windows Apps</title>
<link>
https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed
</link>
<description>Google has announced that Flutter, its open source UI development kit for
building cross-platform software from the same codebase, is finally available for
Windows apps in alpha. From a report:For the world's leading desktop operating system
with some 1 billion installations of Windows 10 alone, this has been a long time coming.
Flutter's alpha incarnation was initially launched at Google's I/O developer conference
back in 2017, before arriving in beta less than a year later. In its original guise,
Flutter was designed for Android and iOS app development, but it has since expanded to
cover the web, MacOS, and Linux, which are currently available in various alpha or beta
iterations. Developers have had to consider unique platform-specific factors when
designing for the desktop or mobile phones, such as different screen sizes and how
people interact with their devices. On smartphones, people typically use touch and
swipe-based gestures, while keyboards and mice are commonly used on PCs and laptops.
This means Flutter has had to expand its support to cover the additional inputs.&lt;p&gt;&lt;div
class="share_submission" style="position:relative;"&gt; &lt;a class="slashpop"
href="http://twitter.com/home?status=Google+Expands+its+Flutter+Development+Kit+To+Windows+Apps%3A+https%3A%2F%2Fbit.ly%2F32X36MW"&gt;&lt;img
src="https://a.fsdn.com/sd/twitter_icon_large.png"&gt;&lt;/a&gt; &lt;a class="slashpop"
href="http://www.facebook.com/sharer.php?u=https%3A%2F%2Fdevelopers.slashdot.org%2Fstory%2F20%2F09%2F23%2F1616231%2Fgoogle-expands-its-flutter-development-kit-to-windows-apps%3Futm_source%3Dslashdot%26utm_medium%3Dfacebook"&gt;&lt;img
src="https://a.fsdn.com/sd/facebook_icon_large.png"&gt;&lt;/a&gt;
&lt;/div&gt;&lt;/p&gt;&lt;p&gt;&lt;a
href="https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0moreanon&amp;amp;utm_medium=feed"&gt;Read
more of this story&lt;/a&gt; at Slashdot.&lt;/p&gt;&lt;iframe
src="https://slashdot.org/slashdot-it.pl?op=discuss&amp;amp;id=17251868&amp;amp;smallembed=1"
style="height: 300px; width: 100%; border: none;"&gt;&lt;/iframe&gt;
</description>
<content:encoded>content:encoded</content:encoded>
<dc:creator>msmash</dc:creator>
<dc:date>2020-09-23T16:15:00+00:00</dc:date>
<dc:subject>programming</dc:subject>
<slash:department>how-about-that</slash:department>
<slash:section>developers</slash:section>
<slash:comments>1</slash:comments>
<slash:hit_parade>1,1,1,1,0,0,0</slash:hit_parade>
</item>
<item
rdf:about="https://news.slashdot.org/story/20/09/23/1528219/firefox-usage-is-down-85-despite-mozillas-top-exec-pay-going-up-400?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed">
<title>Firefox Usage is Down 85% Despite Mozilla's Top Exec Pay Going Up 400%</title>
<link>
https://news.slashdot.org/story/20/09/23/1528219/firefox-usage-is-down-85-despite-mozillas-top-exec-pay-going-up-400?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed
</link>
<description>Software engineer Cal Paterson writes: Mozilla recently announced that they
would be dismissing 250 people. That's a quarter of their workforce so there are some
deep cuts to their work too. The victims include: the MDN docs (those are the web
standards docs everyone likes better than w3schools), the Rust compiler and even some
cuts to Firefox development. Like most people I want to see Mozilla do well but those
three projects comprise pretty much what I think of as the whole point of Mozilla, so
this news is a a big let down. The stated reason for the cuts is falling income. Mozilla
largely relies on "royalties" for funding. In return for payment, Mozilla allows big
technology companies to choose the default search engine in Firefox - the technology
companies are ultimately paying to increase the number of searches Firefox users make
with them. Mozilla haven't been particularly transparent about why these royalties are
being reduced, except to blame the coronavirus. I'm sure the coronavirus is not a great
help but I suspect the bigger problem is that Firefox's market share is now a tiny
fraction of its previous size and so the royalties will be smaller too - fewer users, so
fewer searches and therefore less money for Mozilla.
The real problem is not the royalty cuts, though. Mozilla has already received more than
enough money to set themselves up for financial independence. Mozilla received up to
half a billion dollars a year (each year!) for many years. The real problem is that
Mozilla didn't use that money to achieve financial independence and instead just spent
it each year, doing the organisational equivalent of living hand-to-mouth. Despite their
slightly contrived legal structure as a non-profit that owns a for-profit, Mozilla are
an NGO just like any other. In this article I want to apply the traditional measures
that are applied to other NGOs to Mozilla in order to show what's wrong. These three
measures are: overheads, ethics and results.&lt;p&gt;&lt;div class="share_submission"
style="position:relative;"&gt; &lt;a class="slashpop"
href="http://twitter.com/home?status=Firefox+Usage+is+Down+85%25+Despite+Mozilla's+Top+Exec+Pay+Going+Up+400%25%3A+https%3A%2F%2Fbit.ly%2F33M9FB2"&gt;&lt;img
src="https://a.fsdn.com/sd/twitter_icon_large.png"&gt;&lt;/a&gt; &lt;a class="slashpop"
href="http://www.facebook.com/sharer.php?u=https%3A%2F%2Fnews.slashdot.org%2Fstory%2F20%2F09%2F23%2F1528219%2Ffirefox-usage-is-down-85-despite-mozillas-top-exec-pay-going-up-400%3Futm_source%3Dslashdot%26utm_medium%3Dfacebook"&gt;&lt;img
src="https://a.fsdn.com/sd/facebook_icon_large.png"&gt;&lt;/a&gt;
&lt;/div&gt;&lt;/p&gt;&lt;p&gt;&lt;a
href="https://news.slashdot.org/story/20/09/23/1528219/firefox-usage-is-down-85-despite-mozillas-top-exec-pay-going-up-400?utm_source=rss1.0moreanon&amp;amp;utm_medium=feed"&gt;Read
more of this story&lt;/a&gt; at Slashdot.&lt;/p&gt;&lt;iframe
src="https://slashdot.org/slashdot-it.pl?op=discuss&amp;amp;id=17251650&amp;amp;smallembed=1"
style="height: 300px; width: 100%; border: none;"&gt;&lt;/iframe&gt;
</description>
<dc:creator>msmash</dc:creator>
<dc:date>2020-09-23T15:27:00+00:00</dc:date>
<dc:subject>firefox</dc:subject>
<slash:department>closer-look</slash:department>
<slash:section>news</slash:section>
<slash:comments>31</slash:comments>
<slash:hit_parade>31,29,24,21,3,1,0</slash:hit_parade>
</item>
<item
rdf:about="https://news.slashdot.org/story/20/09/23/1451213/climate-disruption-is-now-locked-in-the-next-moves-will-be-crucial?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed">
<title>Climate Disruption Is Now Locked In. The Next Moves Will Be Crucial.</title>
<link>
https://news.slashdot.org/story/20/09/23/1451213/climate-disruption-is-now-locked-in-the-next-moves-will-be-crucial?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed
</link>
<description>America is now under siege by climate change in ways that scientists have
warned about for years. But there is a second part to their admonition: Decades of
growing crisis are already locked into the global ecosystem and cannot be reversed. From
a report: This means the kinds of cascading disasters occurring today -- drought in the
West fueling historic wildfires that send smoke all the way to the East Coast, or
parades of tropical storms lining up across the Atlantic to march destructively toward
North America -- are no longer features of some dystopian future. They are the here and
now, worsening for the next generation and perhaps longer, depending on humanity's
willingness to take action. "I've been labeled an alarmist," said Peter Kalmus, a
climate scientist in Los Angeles, where he and millions of others have inhaled
dangerously high levels of smoke for weeks. "And I think it's a lot harder for people to
say that I'm being alarmist now." Last month, before the skies over San Francisco turned
a surreal orange, Death Valley reached 130 degrees Fahrenheit, the highest temperature
ever measured on the planet. Dozens of people have perished from the heat in Phoenix,
which in July suffered its hottest month on record, only to surpass that milestone in
August.
Conversations about climate change have broken into everyday life, to the top of the
headlines and to center stage in the presidential campaign. The questions are profound
and urgent. Can this be reversed? What can be done to minimize the looming dangers for
the decades ahead? Will the destruction of recent weeks become a moment of reckoning, or
just a blip in the news cycle? The Times spoke with two dozen climate experts, including
scientists, economists, sociologists and policymakers, and their answers were by turns
alarming, cynical and hopeful. "It's as if we've been smoking a pack of cigarettes a day
for decades" and the world is now feeling the effects, said Katharine Hayhoe, a climate
scientist at Texas Tech University. But, she said, "we're not dead yet." Their most
sobering message was that the world still hasn't seen the worst of it. Gone is the
climate of yesteryear, and there's no going back. The effects of climate change evident
today are the results of choices that countries made decades ago to keep pumping
heat-trapping greenhouse gases into the atmosphere at ever-increasing rates despite
warnings from scientists about the price to be paid.&lt;p&gt;&lt;div
class="share_submission" style="position:relative;"&gt; &lt;a class="slashpop"
href="http://twitter.com/home?status=Climate+Disruption+Is+Now+Locked+In.+The+Next+Moves+Will+Be+Crucial.%3A+https%3A%2F%2Fbit.ly%2F32TsNxO"&gt;&lt;img
src="https://a.fsdn.com/sd/twitter_icon_large.png"&gt;&lt;/a&gt; &lt;a class="slashpop"
href="http://www.facebook.com/sharer.php?u=https%3A%2F%2Fnews.slashdot.org%2Fstory%2F20%2F09%2F23%2F1451213%2Fclimate-disruption-is-now-locked-in-the-next-moves-will-be-crucial%3Futm_source%3Dslashdot%26utm_medium%3Dfacebook"&gt;&lt;img
src="https://a.fsdn.com/sd/facebook_icon_large.png"&gt;&lt;/a&gt;
&lt;/div&gt;&lt;/p&gt;&lt;p&gt;&lt;a
href="https://news.slashdot.org/story/20/09/23/1451213/climate-disruption-is-now-locked-in-the-next-moves-will-be-crucial?utm_source=rss1.0moreanon&amp;amp;utm_medium=feed"&gt;Read
more of this story&lt;/a&gt; at Slashdot.&lt;/p&gt;&lt;iframe
src="https://slashdot.org/slashdot-it.pl?op=discuss&amp;amp;id=17251462&amp;amp;smallembed=1"
style="height: 300px; width: 100%; border: none;"&gt;&lt;/iframe&gt;
</description>
<dc:creator>msmash</dc:creator>
<dc:date>2020-09-23T14:51:00+00:00</dc:date>
<dc:subject>earth</dc:subject>
<slash:department>closer-look</slash:department>
<slash:section>news</slash:section>
<slash:comments>67</slash:comments>
<slash:hit_parade>67,61,50,33,12,2,1</slash:hit_parade>
</item>
<item
rdf:about="https://news.slashdot.org/story/20/09/23/1420240/a-new-york-clock-that-told-time-now-tells-the-time-remaining?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed">
<title>A New York Clock That Told Time Now Tells the Time Remaining</title>
<link>
https://news.slashdot.org/story/20/09/23/1420240/a-new-york-clock-that-told-time-now-tells-the-time-remaining?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed
</link>
<description>For more than 20 years, Metronome, which includes a 62-foot-wide 15-digit
electronic clock that faces Union Square in Manhattan, has been one of the city's most
prominent and baffling public art projects. Its digital display once told the time in
its own unique way, counting the hours, minutes and seconds (and fractions thereof) to
and from midnight. But for years observers who did not understand how it worked
suggested that it was measuring the acres of rainforest destroyed each year, tracking
the world population or even that it had something to do with pi. On Saturday Metronome
adopted a new ecologically sensitive mission. From a report: Now, instead of measuring
24-hour cycles, it is measuring what two artists, Gan Golan and Andrew Boyd, present as
a critical window for action to prevent the effects of global warming from becoming
irreversible. On Saturday at 3:20 p.m., messages including "The Earth has a deadline"
began to appear on the display. Then numbers -- 7:103:15:40:07 -- showed up,
representing the years, days, hours, minutes and seconds until that deadline. As a
handful of supporters watched, the number -- which the artists said was based on
calculations by the Mercator Research Institute on Global Commons and Climate Change in
Berlin -- began ticking down, second by second.
"This is our way to shout that number from the rooftops." Mr. Golan said just before the
countdown began. "The world is literally counting on us." The Climate Clock, as the two
artists call their project, will be displayed on the 14th Street building, One Union
Square South, through Sept. 27, the end of Climate Week. The creators say their aim is
to arrange for the clock to be permanently displayed, there or elsewhere. Mr. Golan said
he came up with the idea to publicly illustrate the urgency of combating climate change
about two years ago, shortly after his daughter was born. He asked Mr. Boyd, an activist
from the Lower East Side, to work with him on the project.&lt;p&gt;&lt;div
class="share_submission" style="position:relative;"&gt; &lt;a class="slashpop"
href="http://twitter.com/home?status=A+New+York+Clock+That+Told+Time+Now+Tells+the+Time+Remaining%3A+https%3A%2F%2Fbit.ly%2F2HrAt2b"&gt;&lt;img
src="https://a.fsdn.com/sd/twitter_icon_large.png"&gt;&lt;/a&gt; &lt;a class="slashpop"
href="http://www.facebook.com/sharer.php?u=https%3A%2F%2Fnews.slashdot.org%2Fstory%2F20%2F09%2F23%2F1420240%2Fa-new-york-clock-that-told-time-now-tells-the-time-remaining%3Futm_source%3Dslashdot%26utm_medium%3Dfacebook"&gt;&lt;img
src="https://a.fsdn.com/sd/facebook_icon_large.png"&gt;&lt;/a&gt;
&lt;/div&gt;&lt;/p&gt;&lt;p&gt;&lt;a
href="https://news.slashdot.org/story/20/09/23/1420240/a-new-york-clock-that-told-time-now-tells-the-time-remaining?utm_source=rss1.0moreanon&amp;amp;utm_medium=feed"&gt;Read
more of this story&lt;/a&gt; at Slashdot.&lt;/p&gt;&lt;iframe
src="https://slashdot.org/slashdot-it.pl?op=discuss&amp;amp;id=17251272&amp;amp;smallembed=1"
style="height: 300px; width: 100%; border: none;"&gt;&lt;/iframe&gt;
</description>
<dc:creator>msmash</dc:creator>
<dc:date>2020-09-23T14:10:00+00:00</dc:date>
<dc:subject>news</dc:subject>
<slash:department>how-about-that</slash:department>
<slash:section>news</slash:section>
<slash:comments>43</slash:comments>
<slash:hit_parade>43,38,33,27,6,1,0</slash:hit_parade>
</item>
</rdf:RDF>

View File

@ -0,0 +1,31 @@
<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:syn="http://purl.org/rss/1.0/modules/syndication/"
xmlns="http://purl.org/rss/1.0/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
<title>Slashdot</title>
<description>News for nerds, stuff that matters</description>
<dc:language>en-us</dc:language>
<dc:rights>Copyright 1997-2016, SlashdotMedia. All Rights Reserved.</dc:rights>
<dc:date>2020-09-23T16:20:20+00:00</dc:date>
<dc:publisher>Dice</dc:publisher>
<dc:creator>help@slashdot.org</dc:creator>
<dc:subject>Technology</dc:subject>
<syn:updateBase>1970-01-01T00:00+00:00</syn:updateBase>
<syn:updateFrequency>1</syn:updateFrequency>
<syn:updatePeriod>hourly</syn:updatePeriod>
<image rdf:resource="https://a.fsdn.com/sd/topics/topicslashdot.gif" />
<textinput rdf:resource="https://slashdot.org/search.pl" />
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" href="http://rss.slashdot.org/slashdot/slashdotMain"
rel="self" type="application/rdf+xml" />
<feedburner:info xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0"
uri="slashdot/slashdotmain" />
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" href="http://pubsubhubbub.appspot.com/"
rel="hub" />
</channel>
<image rdf:about="https://a.fsdn.com/sd/topics/topicslashdot.gif">
<title>Slashdot</title>
<url>https://a.fsdn.com/sd/topics/topicslashdot.gif</url>
<link>https://slashdot.org/</link>
</image>
</rdf:RDF>

View File

@ -0,0 +1,43 @@
<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:syn="http://purl.org/rss/1.0/modules/syndication/"
xmlns="http://purl.org/rss/1.0/">
<item
rdf:about="https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed">
<title>Google Expands its Flutter Development Kit To Windows Apps</title>
<link>
https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed
</link>
<description>Google has announced that Flutter, its open source UI development kit for
building cross-platform software from the same codebase, is finally available for
Windows apps in alpha. From a report:For the world's leading desktop operating system
with some 1 billion installations of Windows 10 alone, this has been a long time coming.
Flutter's alpha incarnation was initially launched at Google's I/O developer conference
back in 2017, before arriving in beta less than a year later. In its original guise,
Flutter was designed for Android and iOS app development, but it has since expanded to
cover the web, MacOS, and Linux, which are currently available in various alpha or beta
iterations. Developers have had to consider unique platform-specific factors when
designing for the desktop or mobile phones, such as different screen sizes and how
people interact with their devices. On smartphones, people typically use touch and
swipe-based gestures, while keyboards and mice are commonly used on PCs and laptops.
This means Flutter has had to expand its support to cover the additional inputs.&lt;p&gt;&lt;div
class="share_submission" style="position:relative;"&gt; &lt;a class="slashpop"
href="http://twitter.com/home?status=Google+Expands+its+Flutter+Development+Kit+To+Windows+Apps%3A+https%3A%2F%2Fbit.ly%2F32X36MW"&gt;&lt;img
src="https://a.fsdn.com/sd/twitter_icon_large.png"&gt;&lt;/a&gt; &lt;a class="slashpop"
href="http://www.facebook.com/sharer.php?u=https%3A%2F%2Fdevelopers.slashdot.org%2Fstory%2F20%2F09%2F23%2F1616231%2Fgoogle-expands-its-flutter-development-kit-to-windows-apps%3Futm_source%3Dslashdot%26utm_medium%3Dfacebook"&gt;&lt;img
src="https://a.fsdn.com/sd/facebook_icon_large.png"&gt;&lt;/a&gt;
&lt;/div&gt;&lt;/p&gt;&lt;p&gt;&lt;a
href="https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0moreanon&amp;amp;utm_medium=feed"&gt;Read
more of this story&lt;/a&gt; at Slashdot.&lt;/p&gt;&lt;iframe
src="https://slashdot.org/slashdot-it.pl?op=discuss&amp;amp;id=17251868&amp;amp;smallembed=1"
style="height: 300px; width: 100%; border: none;"&gt;&lt;/iframe&gt;
</description>
<dc:creator>msmash</dc:creator>
<dc:subject>programming</dc:subject>
<slash:department>how-about-that</slash:department>
<slash:section>developers</slash:section>
<slash:comments>1</slash:comments>
<slash:hit_parade>1,1,1,1,0,0,0</slash:hit_parade>
</item>
</rdf:RDF>

View File

@ -0,0 +1,40 @@
<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:syn="http://purl.org/rss/1.0/modules/syndication/"
xmlns="http://purl.org/rss/1.0/">
<item>
<title>Google Expands its Flutter Development Kit To Windows Apps</title>
<description>Google has announced that Flutter, its open source UI development kit for
building cross-platform software from the same codebase, is finally available for
Windows apps in alpha. From a report:For the world's leading desktop operating system
with some 1 billion installations of Windows 10 alone, this has been a long time coming.
Flutter's alpha incarnation was initially launched at Google's I/O developer conference
back in 2017, before arriving in beta less than a year later. In its original guise,
Flutter was designed for Android and iOS app development, but it has since expanded to
cover the web, MacOS, and Linux, which are currently available in various alpha or beta
iterations. Developers have had to consider unique platform-specific factors when
designing for the desktop or mobile phones, such as different screen sizes and how
people interact with their devices. On smartphones, people typically use touch and
swipe-based gestures, while keyboards and mice are commonly used on PCs and laptops.
This means Flutter has had to expand its support to cover the additional inputs.&lt;p&gt;&lt;div
class="share_submission" style="position:relative;"&gt; &lt;a class="slashpop"
href="http://twitter.com/home?status=Google+Expands+its+Flutter+Development+Kit+To+Windows+Apps%3A+https%3A%2F%2Fbit.ly%2F32X36MW"&gt;&lt;img
src="https://a.fsdn.com/sd/twitter_icon_large.png"&gt;&lt;/a&gt; &lt;a class="slashpop"
href="http://www.facebook.com/sharer.php?u=https%3A%2F%2Fdevelopers.slashdot.org%2Fstory%2F20%2F09%2F23%2F1616231%2Fgoogle-expands-its-flutter-development-kit-to-windows-apps%3Futm_source%3Dslashdot%26utm_medium%3Dfacebook"&gt;&lt;img
src="https://a.fsdn.com/sd/facebook_icon_large.png"&gt;&lt;/a&gt;
&lt;/div&gt;&lt;/p&gt;&lt;p&gt;&lt;a
href="https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0moreanon&amp;amp;utm_medium=feed"&gt;Read
more of this story&lt;/a&gt; at Slashdot.&lt;/p&gt;&lt;iframe
src="https://slashdot.org/slashdot-it.pl?op=discuss&amp;amp;id=17251868&amp;amp;smallembed=1"
style="height: 300px; width: 100%; border: none;"&gt;&lt;/iframe&gt;
</description>
<dc:creator>msmash</dc:creator>
<dc:date>2020-09-23T16:15:00+00:00</dc:date>
<dc:subject>programming</dc:subject>
<slash:department>how-about-that</slash:department>
<slash:section>developers</slash:section>
<slash:comments>1</slash:comments>
<slash:hit_parade>1,1,1,1,0,0,0</slash:hit_parade>
</item>
</rdf:RDF>

View File

@ -0,0 +1,43 @@
<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:syn="http://purl.org/rss/1.0/modules/syndication/"
xmlns="http://purl.org/rss/1.0/">
<item
rdf:about="https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed">
<link>
https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed
</link>
<description>Google has announced that Flutter, its open source UI development kit for
building cross-platform software from the same codebase, is finally available for
Windows apps in alpha. From a report:For the world's leading desktop operating system
with some 1 billion installations of Windows 10 alone, this has been a long time coming.
Flutter's alpha incarnation was initially launched at Google's I/O developer conference
back in 2017, before arriving in beta less than a year later. In its original guise,
Flutter was designed for Android and iOS app development, but it has since expanded to
cover the web, MacOS, and Linux, which are currently available in various alpha or beta
iterations. Developers have had to consider unique platform-specific factors when
designing for the desktop or mobile phones, such as different screen sizes and how
people interact with their devices. On smartphones, people typically use touch and
swipe-based gestures, while keyboards and mice are commonly used on PCs and laptops.
This means Flutter has had to expand its support to cover the additional inputs.&lt;p&gt;&lt;div
class="share_submission" style="position:relative;"&gt; &lt;a class="slashpop"
href="http://twitter.com/home?status=Google+Expands+its+Flutter+Development+Kit+To+Windows+Apps%3A+https%3A%2F%2Fbit.ly%2F32X36MW"&gt;&lt;img
src="https://a.fsdn.com/sd/twitter_icon_large.png"&gt;&lt;/a&gt; &lt;a class="slashpop"
href="http://www.facebook.com/sharer.php?u=https%3A%2F%2Fdevelopers.slashdot.org%2Fstory%2F20%2F09%2F23%2F1616231%2Fgoogle-expands-its-flutter-development-kit-to-windows-apps%3Futm_source%3Dslashdot%26utm_medium%3Dfacebook"&gt;&lt;img
src="https://a.fsdn.com/sd/facebook_icon_large.png"&gt;&lt;/a&gt;
&lt;/div&gt;&lt;/p&gt;&lt;p&gt;&lt;a
href="https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0moreanon&amp;amp;utm_medium=feed"&gt;Read
more of this story&lt;/a&gt; at Slashdot.&lt;/p&gt;&lt;iframe
src="https://slashdot.org/slashdot-it.pl?op=discuss&amp;amp;id=17251868&amp;amp;smallembed=1"
style="height: 300px; width: 100%; border: none;"&gt;&lt;/iframe&gt;
</description>
<dc:creator>msmash</dc:creator>
<dc:date>2020-09-23T16:15:00+00:00</dc:date>
<dc:subject>programming</dc:subject>
<slash:department>how-about-that</slash:department>
<slash:section>developers</slash:section>
<slash:comments>1</slash:comments>
<slash:hit_parade>1,1,1,1,0,0,0</slash:hit_parade>
</item>
</rdf:RDF>

View File

@ -0,0 +1,120 @@
<rdf:RDF xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/" xmlns:syn="http://purl.org/rss/1.0/modules/syndication/"
xmlns="http://purl.org/rss/1.0/">
<channel rdf:about="https://slashdot.org/">
<title>Slashdot</title>
<link>https://slashdot.org/</link>
<description>News for nerds, stuff that matters</description>
<dc:language>en-us</dc:language>
<dc:rights>Copyright 1997-2016, SlashdotMedia. All Rights Reserved.</dc:rights>
<dc:date>2020-09-23T16:20:20+00:00</dc:date>
<dc:publisher>Dice</dc:publisher>
<dc:creator>help@slashdot.org</dc:creator>
<dc:subject>Technology</dc:subject>
<syn:updateBase>1970-01-01T00:00+00:00</syn:updateBase>
<syn:updateFrequency>1</syn:updateFrequency>
<syn:updatePeriod>hourly</syn:updatePeriod>
<items>
<rdf:Seq>
<rdf:li
rdf:resource="https://developers.slashdot.org/story/20/09/23/1616231/google-expands-its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/1528219/firefox-usage-is-down-85-despite-mozillas-top-exec-pay-going-up-400?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/1451213/climate-disruption-is-now-locked-in-the-next-moves-will-be-crucial?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/1420240/a-new-york-clock-that-told-time-now-tells-the-time-remaining?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/0057256/jeff-bezos-is-opening-his-first-tuition-free-bezos-academy-preschool-where-each-child-will-be-the-customer?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://news.slashdot.org/story/20/09/23/0050249/google-is-pulling-the-plug-on-paid-chrome-extensions-over-the-next-year?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://entertainment.slashdot.org/story/20/09/22/2147243/old-tv-caused-village-broadband-outages-for-18-months?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://mobile.slashdot.org/story/20/09/22/2316256/t-mobile-amassed-unprecedented-concentration-of-spectrum-att-complains?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://yro.slashdot.org/story/20/09/22/2030223/dark-web-drugs-raid-leads-to-179-arrests?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://hardware.slashdot.org/story/20/09/22/2055203/the-fairphone-3-is-a-repairable-dream-that-takes-beautiful-photos?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://tech.slashdot.org/story/20/09/23/0039222/tesla-unveils-model-s-plaid-520-miles-200-mph-and-0-60-mph-in-less-than-2s?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://apple.slashdot.org/story/20/09/22/211222/apple-ceo-impressed-by-remote-work-sees-permanent-changes?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://hardware.slashdot.org/story/20/09/22/236228/tesla-announces-tabless-battery-cells-that-will-improve-range-of-its-electric-cars?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://linux.slashdot.org/story/20/09/22/2243209/linux-journal-is-back?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
<rdf:li
rdf:resource="https://hardware.slashdot.org/story/20/09/22/2026238/shell-reportedly-to-slash-oil-and-gas-production-costs-to-focus-more-on-renewables?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed" />
</rdf:Seq>
</items>
<image rdf:resource="https://a.fsdn.com/sd/topics/topicslashdot.gif" />
<textinput rdf:resource="https://slashdot.org/search.pl" />
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" href="http://rss.slashdot.org/slashdot/slashdotMain"
rel="self" type="application/rdf+xml" />
<feedburner:info xmlns:feedburner="http://rssnamespace.org/feedburner/ext/1.0"
uri="slashdot/slashdotmain" />
<atom10:link xmlns:atom10="http://www.w3.org/2005/Atom" href="http://pubsubhubbub.appspot.com/"
rel="hub" />
</channel>
<image rdf:about="https://a.fsdn.com/sd/topics/topicslashdot.gif">
<title>Slashdot</title>
<url>https://a.fsdn.com/sd/topics/topicslashdot.gif</url>
<link>https://slashdot.org/</link>
</image>
<item
rdf:about="https://news.slashdot.org/story/20/09/23/1420240/a-new-york-clock-that-told-time-now-tells-the-time-remaining?utm_source=rss1.0mainlinkanon&amp;utm_medium=feed">
<title>A New York Clock That Told Time Now Tells the Time Remaining</title>
<description>For more than 20 years, Metronome, which includes a 62-foot-wide 15-digit
electronic clock that faces Union Square in Manhattan, has been one of the city's most
prominent and baffling public art projects. Its digital display once told the time in
its own unique way, counting the hours, minutes and seconds (and fractions thereof) to
and from midnight. But for years observers who did not understand how it worked
suggested that it was measuring the acres of rainforest destroyed each year, tracking
the world population or even that it had something to do with pi. On Saturday Metronome
adopted a new ecologically sensitive mission. From a report: Now, instead of measuring
24-hour cycles, it is measuring what two artists, Gan Golan and Andrew Boyd, present as
a critical window for action to prevent the effects of global warming from becoming
irreversible. On Saturday at 3:20 p.m., messages including "The Earth has a deadline"
began to appear on the display. Then numbers -- 7:103:15:40:07 -- showed up,
representing the years, days, hours, minutes and seconds until that deadline. As a
handful of supporters watched, the number -- which the artists said was based on
calculations by the Mercator Research Institute on Global Commons and Climate Change in
Berlin -- began ticking down, second by second.
"This is our way to shout that number from the rooftops." Mr. Golan said just before the
countdown began. "The world is literally counting on us." The Climate Clock, as the two
artists call their project, will be displayed on the 14th Street building, One Union
Square South, through Sept. 27, the end of Climate Week. The creators say their aim is
to arrange for the clock to be permanently displayed, there or elsewhere. Mr. Golan said
he came up with the idea to publicly illustrate the urgency of combating climate change
about two years ago, shortly after his daughter was born. He asked Mr. Boyd, an activist
from the Lower East Side, to work with him on the project.&lt;p&gt;&lt;div
class="share_submission" style="position:relative;"&gt; &lt;a class="slashpop"
href="http://twitter.com/home?status=A+New+York+Clock+That+Told+Time+Now+Tells+the+Time+Remaining%3A+https%3A%2F%2Fbit.ly%2F2HrAt2b"&gt;&lt;img
src="https://a.fsdn.com/sd/twitter_icon_large.png"&gt;&lt;/a&gt; &lt;a class="slashpop"
href="http://www.facebook.com/sharer.php?u=https%3A%2F%2Fnews.slashdot.org%2Fstory%2F20%2F09%2F23%2F1420240%2Fa-new-york-clock-that-told-time-now-tells-the-time-remaining%3Futm_source%3Dslashdot%26utm_medium%3Dfacebook"&gt;&lt;img
src="https://a.fsdn.com/sd/facebook_icon_large.png"&gt;&lt;/a&gt;
&lt;/div&gt;&lt;/p&gt;&lt;p&gt;&lt;a
href="https://news.slashdot.org/story/20/09/23/1420240/a-new-york-clock-that-told-time-now-tells-the-time-remaining?utm_source=rss1.0moreanon&amp;amp;utm_medium=feed"&gt;Read
more of this story&lt;/a&gt; at Slashdot.&lt;/p&gt;&lt;iframe
src="https://slashdot.org/slashdot-it.pl?op=discuss&amp;amp;id=17251272&amp;amp;smallembed=1"
style="height: 300px; width: 100%; border: none;"&gt;&lt;/iframe&gt;
</description>
<dc:creator>msmash</dc:creator>
<dc:creator></dc:creator>
<dc:creator>creator 2</dc:creator>
<dc:creator>creator 3</dc:creator>
<dc:creator>creator 4</dc:creator>
<dc:creator>creator 5</dc:creator>
<dc:date>2020-09-23T14:10:00+00:00</dc:date>
<dc:subject>news</dc:subject>
<slash:department>how-about-that</slash:department>
<slash:section>news</slash:section>
<slash:comments>43</slash:comments>
<slash:hit_parade>43,38,33,27,6,1,0</slash:hit_parade>
</item>
</rdf:RDF>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title></title>
<atom:link href="https://news.ycombinator.com/feed/" rel="self"/>
<link>https://news.ycombinator.com/</link>
<description>Links for the intellectually curious, ranked by readers.</description>
</channel>
</rss>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>Hacker News</title>
<atom:link href="https://news.ycombinator.com/feed/" rel="self" />
<atom:link href="https://news.ycombinator.com/hub" rel="hub" />
<link>https://news.ycombinator.com/</link>
<description>Links for the intellectually curious, ranked by readers.</description>
</channel>
</rss>

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"
version="2.0">
<channel>
<item>
<title>title</title>
<link>link</link>
<dc:creator><![CDATA[creator]]></dc:creator>
<dc:date>2020-08-05T14:03:48Z</dc:date>
<category><![CDATA[Category 1]]></category>
<category><![CDATA[Category 2]]></category>
<category><![CDATA[Category 3]]></category>
<category><![CDATA[Category 4]]></category>
<category><![CDATA[Category 5]]></category>
<category><![CDATA[Category 6]]></category>
<guid isPermaLink="false">guid</guid>
<description><![CDATA[description]]></description>
<content:encoded><![CDATA[content:encoded]]></content:encoded>
<enclosure length="0" type="image/jpg" url="https://image1.jpg" />
<media:content medium="image" url="https://image2.jpg"/>
</item>
</channel>
</rss>

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"
version="2.0">
<channel>
<item>
<title>title</title>
<link>link</link>
<dc:creator><![CDATA[creator]]></dc:creator>
<dc:date>2020-08-05T14:03:48Z</dc:date>
<category><![CDATA[Category 1]]></category>
<category><![CDATA[Category 2]]></category>
<category><![CDATA[Category 3]]></category>
<category><![CDATA[Category 4]]></category>
<category><![CDATA[Category 5]]></category>
<category><![CDATA[Category 6]]></category>
<guid isPermaLink="false">guid</guid>
<description><![CDATA[description]]></description>
<content:encoded><![CDATA[content:encoded]]></content:encoded>
<media:content medium="image" url="https://image1.jpg"><media:title>image1 title</media:title></media:content>
</item>
<item>
<title>title</title>
<link>link</link>
<dc:creator><![CDATA[creator]]></dc:creator>
<dc:date>2020-08-05T14:03:48Z</dc:date>
<category><![CDATA[Category 1]]></category>
<category><![CDATA[Category 2]]></category>
<category><![CDATA[Category 3]]></category>
<category><![CDATA[Category 4]]></category>
<category><![CDATA[Category 5]]></category>
<category><![CDATA[Category 6]]></category>
<guid isPermaLink="false">guid</guid>
<description><![CDATA[description]]></description>
<content:encoded><![CDATA[content:encoded]]></content:encoded>
<media:content type="image/jpeg" url="https://image2.jpg"><media:title>image2 title</media:title></media:content>
</item>
</channel>
</rss>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"
version="2.0">
<channel>
<item>
<title>title</title>
<link>link</link>
<dc:creator><![CDATA[creator]]></dc:creator>
<dc:date>2020-08-05T14:03:48Z</dc:date>
<category><![CDATA[Category 1]]></category>
<category><![CDATA[Category 2]]></category>
<category><![CDATA[Category 3]]></category>
<category><![CDATA[Category 4]]></category>
<category><![CDATA[Category 5]]></category>
<category><![CDATA[Category 6]]></category>
<guid isPermaLink="false">guid</guid>
<description><![CDATA[description]]></description>
<content:encoded><![CDATA[content:encoded]]></content:encoded>
<media:group>
<media:content medium="image" url="https://image1.jpg" />
<media:content medium="image" url="https://image2.jpg">
<media:title>image2 title</media:title>
</media:content>
</media:group>
</item>
</channel>
</rss>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<item>
<title>title</title>
<link>link</link>
<dc:creator><![CDATA[creator]]></dc:creator>
<category><![CDATA[Category 1]]></category>
<category><![CDATA[Category 2]]></category>
<category><![CDATA[Category 3]]></category>
<category><![CDATA[Category 4]]></category>
<category><![CDATA[Category 5]]></category>
<category><![CDATA[Category 6]]></category>
<guid isPermaLink="false">guid</guid>
<description><![CDATA[description]]></description>
<content:encoded><![CDATA[content:encoded]]></content:encoded>
</item>
</channel>
</rss>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<item>
<title>title</title>
<dc:creator><![CDATA[creator]]></dc:creator>
<dc:date>2020-08-05T14:03:48Z</dc:date>
<category><![CDATA[Category 1]]></category>
<category><![CDATA[Category 2]]></category>
<category><![CDATA[Category 3]]></category>
<category><![CDATA[Category 4]]></category>
<category><![CDATA[Category 5]]></category>
<category><![CDATA[Category 6]]></category>
<guid isPermaLink="false">guid</guid>
<description><![CDATA[description]]></description>
<content:encoded><![CDATA[content:encoded]]></content:encoded>
</item>
</channel>
</rss>

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<item>
<link>link</link>
<dc:creator><![CDATA[creator]]></dc:creator>
<dc:date>2020-08-05T14:03:48Z</dc:date>
<category><![CDATA[Category 1]]></category>
<category><![CDATA[Category 2]]></category>
<category><![CDATA[Category 3]]></category>
<category><![CDATA[Category 4]]></category>
<category><![CDATA[Category 5]]></category>
<category><![CDATA[Category 6]]></category>
<guid isPermaLink="false">guid</guid>
<description><![CDATA[description]]></description>
<content:encoded><![CDATA[content:encoded]]></content:encoded>
</item>
</channel>
</rss>

View File

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
<channel>
<item>
<title>title</title>
<link>link</link>
<guid>guid</guid>
<dc:creator><![CDATA[creator 1]]></dc:creator>
<dc:creator><![CDATA[creator 2]]></dc:creator>
<dc:creator><![CDATA[creator 3]]></dc:creator>
<dc:creator><![CDATA[creator 4]]></dc:creator>
<dc:date>2020-08-05T14:03:48Z</dc:date>
<category><![CDATA[Category 1]]></category>
<category><![CDATA[Category 2]]></category>
<category><![CDATA[Category 3]]></category>
<category><![CDATA[Category 4]]></category>
<category><![CDATA[Category 5]]></category>
<category><![CDATA[Category 6]]></category>
<guid isPermaLink="false">guid</guid>
<description><![CDATA[description]]></description>
<content:encoded><![CDATA[content:encoded]]></content:encoded>
</item>
</channel>
</rss>

View File

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/">
<channel>
<title>Hacker News</title>
<link>https://news.ycombinator.com/</link>
<description>Links for the intellectually curious, ranked by readers.</description>
<item>
<title>Africa declared free of wild polio</title>
<link>https://www.bbc.com/news/world-africa-53887947</link>
<pubDate>Tue, 25 Aug 2020 17:15:49 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24273602</comments>
<author>Author 1</author>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273602">Comments</a>]]></description>
<media:description>media description</media:description>
</item>
<item>
<title>Palantir S-1</title>
<link>https://www.sec.gov/Archives/edgar/data/1321655/000119312520230013/d904406ds1.htm</link>
<pubDate>Tue, 25 Aug 2020 21:03:42 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24276086</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24276086">Comments</a>]]></description>
</item>
<item>
<title>Openwifi: Linux mac80211 compatible full-stack 802.11/Wi-Fi design based on SDR</title>
<link>https://github.com/open-sdr/openwifi</link>
<pubDate>Tue, 25 Aug 2020 17:45:19 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24273919</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273919">Comments</a>]]></description>
</item>
<item>
<title>Syllabus for Eric's PhD Students</title>
<link>https://docs.google.com/document/d/11D3kHElzS2HQxTwPqcaTnU5HCJ8WGE5brTXI4KLf4dM/edit</link>
<pubDate>Tue, 25 Aug 2020 18:55:12 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24274699</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274699">Comments</a>]]></description>
</item>
<item>
<title>WebBundles harmful to content blocking, security tools, and the open web</title>
<link>https://brave.com/webbundles-harmful-to-content-blocking-security-tools-and-the-open-web/</link>
<pubDate>Tue, 25 Aug 2020 19:18:50 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24274968</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274968">Comments</a>]]></description>
</item>
<item>
<title>Zappos CEO Tony Hsieh is stepping down after 21 years</title>
<link>https://footwearnews.com/2020/business/executive-moves/zappos-ceo-tony-hsieh-steps-down-1203045974/</link>
<pubDate>Tue, 25 Aug 2020 06:11:42 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24268522</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24268522">Comments</a>]]></description>
</item>
<item>
<title>Evgeny Kuznetsov practices with Bauer stick that has hole in the blade</title>
<link>https://russianmachineneverbreaks.com/2020/07/17/evgeny-kuznetsov-practices-with-bauer-stick-that-has-hole-in-the-blade/</link>
<pubDate>Tue, 25 Aug 2020 19:38:09 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24275159</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24275159">Comments</a>]]></description>
</item>
</channel>
</rss>

View File

@ -29,7 +29,7 @@ class OPMLParserTest {
@Test
fun readOpmlTest() {
val stream = context.resources.assets.open("subscriptions.opml")
val stream = context.resources.assets.open("opml/subscriptions.opml")
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
@ -52,7 +52,7 @@ class OPMLParserTest {
@Test
fun readLiteSubscriptionsTest() {
val stream = context.resources.assets.open("lite_subscriptions.opml")
val stream = context.resources.assets.open("opml/lite_subscriptions.opml")
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
@ -68,7 +68,7 @@ class OPMLParserTest {
@Test
fun opmlVersionTest() {
val stream = context.resources.assets.open("wrong_version.opml")
val stream = context.resources.assets.open("opml/wrong_version.opml")
OPMLParser.read(stream)
.test()

View File

@ -0,0 +1,190 @@
package com.readrops.api.localfeed
import android.accounts.NetworkErrorException
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.readrops.api.utils.HttpManager
import com.readrops.api.utils.LibUtils
import com.readrops.api.utils.ParseException
import com.readrops.api.utils.UnknownFormatException
import junit.framework.TestCase.*
import okhttp3.Headers
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.net.HttpURLConnection
@RunWith(AndroidJUnit4::class)
class LocalRSSDataSourceTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().context
private lateinit var url: HttpUrl
private val mockServer: MockWebServer = MockWebServer()
private val localRSSDataSource = LocalRSSDataSource(HttpManager.getInstance().okHttpClient)
@Before
fun before() {
mockServer.start(8080)
url = mockServer.url("/rss")
}
@After
fun tearDown() {
mockServer.close()
}
@Test
fun successfulQueryTest() {
val stream = context.resources.assets.open("localfeed/rss_feed.xml")
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(LibUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8")
.addHeader(LibUtils.ETAG_HEADER, "ETag-value")
.addHeader(LibUtils.LAST_MODIFIED_HEADER, "Last-Modified")
.setBody(Buffer().readFrom(stream)))
val pair = localRSSDataSource.queryRSSResource(url.toString(), null)
val feed = pair?.first!!
assertEquals(feed.name, "Hacker News")
assertEquals(feed.url, "http://localhost:8080/rss")
assertEquals(feed.siteUrl, "https://news.ycombinator.com/")
assertEquals(feed.description, "Links for the intellectually curious, ranked by readers.")
assertEquals(feed.etag, "ETag-value")
assertEquals(feed.lastModified, "Last-Modified")
assertEquals(pair.second.size, 7)
}
@Test
fun headersTest() {
val stream = context.resources.assets.open("localfeed/rss_feed.xml")
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader("Content-Type", "application/rss+xml; charset=UTF-8")
.setBody(Buffer().readFrom(stream)))
val headers = Headers.headersOf(LibUtils.ETAG_HEADER, "ETag", LibUtils.LAST_MODIFIED_HEADER, "Last-Modified")
localRSSDataSource.queryRSSResource(url.toString(), headers)
val request = mockServer.takeRequest()
assertEquals(request.headers[LibUtils.ETAG_HEADER], "ETag")
assertEquals(request.headers[LibUtils.LAST_MODIFIED_HEADER], "Last-Modified")
}
@Test
fun jsonFeedTest() {
val stream = context.resources.assets.open("localfeed/json/json_feed.json")
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(LibUtils.CONTENT_TYPE_HEADER, "application/feed+json")
.setBody(Buffer().readFrom(stream)))
val pair = localRSSDataSource.queryRSSResource(url.toString(), null)!!
assertEquals(pair.first.name, "News from Flying Meat")
assertEquals(pair.second.size, 10)
}
@Test
fun specialCasesAtomTest() {
val stream = context.resources.assets.open("localfeed/atom/atom_feed_no_url_siteurl.xml")
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(LibUtils.CONTENT_TYPE_HEADER, "application/atom+xml")
.setBody(Buffer().readFrom(stream)))
val pair = localRSSDataSource.queryRSSResource(url.toString(), null)!!
assertEquals(pair.first.url, "http://localhost:8080/rss")
assertEquals(pair.first.siteUrl, "http://localhost")
}
@Test
fun specialCasesRSS1Test() {
val stream = context.resources.assets.open("localfeed/rss1/rss1_feed_no_url_siteurl.xml")
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(LibUtils.CONTENT_TYPE_HEADER, "application/rdf+xml")
.setBody(Buffer().readFrom(stream)))
val pair = localRSSDataSource.queryRSSResource(url.toString(), null)!!
assertEquals(pair.first.url, "http://localhost:8080/rss")
assertEquals(pair.first.siteUrl, "http://localhost")
}
@Test
fun response304Test() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED))
val pair = localRSSDataSource.queryRSSResource(url.toString(), null)
assertNull(pair)
}
@Test(expected = NetworkErrorException::class)
fun response404Test() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
localRSSDataSource.queryRSSResource(url.toString(), null)
}
@Test(expected = UnknownFormatException::class)
fun noContentTypeTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK))
localRSSDataSource.queryRSSResource(url.toString(), null)
}
@Test(expected = ParseException::class)
fun badContentTypeTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader("Content-Type", ""))
localRSSDataSource.queryRSSResource(url.toString(), null)
}
@Test(expected = UnknownFormatException::class)
fun badContentTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader("Content-Type", "application/xml")
.setBody("<html> </html>"))
localRSSDataSource.queryRSSResource(url.toString(), null)
}
@Test
fun isUrlResourceSuccessfulTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader("Content-Type", "application/atom+xml; charset=UTF-8"))
assertTrue(localRSSDataSource.isUrlRSSResource(url.toString()))
}
@Test
fun isUrlRSSResourceFailureTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
assertFalse(localRSSDataSource.isUrlRSSResource(url.toString()))
}
@Test
fun isUrlRSSResourceBadContentTypeTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader("Content-Type", "application/xml; charset=UTF-8")
.setBody("<html> </html>"))
assertFalse(localRSSDataSource.isUrlRSSResource(url.toString()))
}
}

View File

@ -0,0 +1,28 @@
package com.readrops.api.localfeed.atom
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import junit.framework.TestCase.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ATOMFeedAdapterTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().context
private val adapter = ATOMFeedAdapter()
@Test
fun normalCasesTest() {
val stream = context.assets.open("localfeed/atom/atom_feed.xml")
val feed = adapter.fromXml(stream)
assertEquals(feed.name, "Recent Commits to Readrops:develop")
assertEquals(feed.url, "https://github.com/readrops/Readrops/commits/develop.atom")
assertEquals(feed.siteUrl, "https://github.com/readrops/Readrops/commits/develop")
assertEquals(feed.description, "Here is a subtitle")
}
}

View File

@ -0,0 +1,61 @@
package com.readrops.api.localfeed.atom
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.ParseException
import junit.framework.TestCase.*
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ATOMItemsAdapterTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().context
private val adapter = ATOMItemsAdapter()
@Test
fun normalCasesTest() {
val stream = context.resources.assets.open("localfeed/atom/atom_items.xml")
val items = adapter.fromXml(stream)
val item = items[0]
assertEquals(items.size, 4)
assertEquals(item.title, "Add an option to open item url in custom tab")
assertEquals(item.link, "https://github.com/readrops/Readrops/commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
assertEquals(item.pubDate, DateUtils.parse("2020-09-06T21:09:59Z"))
assertEquals(item.author, "Shinokuni")
assertEquals(item.description, "Summary")
assertEquals(item.guid, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
assertNotNull(item.content)
}
@Test
fun noDateTest() {
val stream = context.resources.assets.open("localfeed/atom/atom_items_no_date.xml")
val item = adapter.fromXml(stream).first()
assertNotNull(item.pubDate)
}
@Test
fun noTitleTest() {
val stream = context.resources.assets.open("localfeed/atom/atom_items_no_title.xml")
val exception = Assert.assertThrows(ParseException::class.java) { adapter.fromXml(stream) }
assertTrue(exception.message!!.contains("Item title is required"))
}
@Test
fun noLinkTest() {
val stream = context.resources.assets.open("localfeed/atom/atom_items_no_link.xml")
val exception = Assert.assertThrows(ParseException::class.java) { adapter.fromXml(stream) }
assertTrue(exception.message!!.contains("Item link is required"))
}
}

View File

@ -0,0 +1,35 @@
package com.readrops.api.localfeed.json
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.readrops.db.entities.Feed
import com.squareup.moshi.Moshi
import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class JSONFeedAdapterTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().context
private val adapter = Moshi.Builder()
.add(JSONFeedAdapter())
.build()
.adapter<Feed>(Feed::class.java)
@Test
fun normalCasesTest() {
val stream = context.assets.open("localfeed/json/json_feed.json")
val feed = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(feed.name, "News from Flying Meat")
assertEquals(feed.url, "http://flyingmeat.com/blog/feed.json")
assertEquals(feed.siteUrl, "http://flyingmeat.com/blog/")
assertEquals(feed.description, "News from your friends at Flying Meat.")
}
}

View File

@ -0,0 +1,80 @@
package com.readrops.api.localfeed.json
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.ParseException
import com.readrops.db.entities.Item
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import junit.framework.TestCase.*
import okio.Buffer
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class JSONItemsAdapterTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().context
private val adapter = Moshi.Builder()
.add(Types.newParameterizedType(List::class.java, Item::class.java), JSONItemsAdapter())
.build()
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
@Test
fun normalCasesTest() {
val stream = context.resources.assets.open("localfeed/json/json_feed.json")
val items = adapter.fromJson(Buffer().readFrom(stream))!!
val item = items.first()
assertEquals(items.size, 10)
assertEquals(item.guid, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
assertEquals(item.title, "Acorn and 10.13")
assertEquals(item.link, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
assertEquals(item.pubDate, DateUtils.parse("2017-09-25T14:27:27-07:00"))
assertEquals(item.author, "Author 1")
assertNotNull(item.content)
}
@Test
fun otherCasesTest() {
val stream = context.resources.assets.open("localfeed/json/json_items_other_cases.json")
val item = adapter.fromJson(Buffer().readFrom(stream))!!.first()
assertEquals(item.description, "This is a summary")
assertEquals(item.content, "content_html")
assertEquals(item.imageLink, "https://image.com")
assertEquals(item.author, "Author 1, Author 3, Author 4, Author 5, ...")
}
@Test
fun nullDateTest() {
val stream = context.resources.assets.open("localfeed/json/json_items_no_date.json")
val item = adapter.fromJson(Buffer().readFrom(stream))!!.first()
assertNotNull(item.pubDate)
}
@Test
fun nullTitleTest() {
val stream = context.resources.assets.open("localfeed/json/json_items_no_title.json")
val exception = Assert.assertThrows(ParseException::class.java) { adapter.fromJson(Buffer().readFrom(stream)) }
assertTrue(exception.message!!.contains("Item title is required"))
}
@Test
fun nullLinkTest() {
val stream = context.resources.assets.open("localfeed/json/json_items_no_link.json")
val exception = Assert.assertThrows(ParseException::class.java) { adapter.fromJson(Buffer().readFrom(stream)) }
assertTrue(exception.message!!.contains("Item link is required"))
}
}

View File

@ -0,0 +1,28 @@
package com.readrops.api.localfeed.rss1
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import junit.framework.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RSS1FeedAdapterTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().context
private val adapter = RSS1FeedAdapter()
@Test
fun normalCaseTest() {
val stream = context.resources.assets.open("localfeed/rss1/rss1_feed.xml")
val feed = adapter.fromXml(stream)
assertEquals(feed.name, "Slashdot")
assertEquals(feed.url, "https://slashdot.org/")
assertEquals(feed.siteUrl, "https://slashdot.org/")
assertEquals(feed.description, "News for nerds, stuff that matters")
}
}

View File

@ -0,0 +1,73 @@
package com.readrops.api.localfeed.rss1
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.ParseException
import junit.framework.TestCase.*
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RSS1ItemsAdapterTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().context
private val adapter = RSS1ItemsAdapter()
@Test
fun normalCasesTest() {
val stream = context.resources.assets.open("localfeed/rss1/rss1_feed.xml")
val items = adapter.fromXml(stream)
val item = items.first()
assertEquals(items.size, 4)
assertEquals(item.title, "Google Expands its Flutter Development Kit To Windows Apps")
assertEquals(item.link.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" +
"its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&utm_medium=feed")
assertEquals(item.guid.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" +
"its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&utm_medium=feed")
assertEquals(item.pubDate, DateUtils.parse("2020-09-23T16:15:00+00:00"))
assertEquals(item.author, "msmash")
assertNotNull(item.description)
assertEquals(item.content, "content:encoded")
}
@Test
fun specialCasesTest() {
val stream = context.resources.assets.open("localfeed/rss1/rss1_items_special_cases.xml")
val item = adapter.fromXml(stream).first()
assertEquals(item.author, "msmash, creator 2, creator 3, creator 4, ...")
assertEquals(item.link, "https://news.slashdot.org/story/20/09/23/1420240/a-new-york-clock-" +
"that-told-time-now-tells-the-time-remaining?utm_source=rss1.0mainlinkanon&utm_medium=feed")
}
@Test
fun nullDateTest() {
val stream = context.resources.assets.open("localfeed/rss1/rss1_items_no_date.xml")
val item = adapter.fromXml(stream).first()
assertNotNull(item.pubDate)
}
@Test
fun nullTitleTest() {
val stream = context.resources.assets.open("localfeed/rss1/rss1_items_no_title.xml")
val exception = Assert.assertThrows(ParseException::class.java) { adapter.fromXml(stream) }
assertTrue(exception.message!!.contains("Item title is required"))
}
@Test
fun nullLinkTest() {
val stream = context.resources.assets.open("localfeed/rss1/rss1_items_no_link.xml")
val exception = Assert.assertThrows(ParseException::class.java) { adapter.fromXml(stream) }
assertTrue(exception.message!!.contains("RSS1 link or about element is required"))
}
}

View File

@ -0,0 +1,36 @@
package com.readrops.api.localfeed.rss2
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.readrops.api.utils.ParseException
import junit.framework.TestCase.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RSS2FeedAdapterTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().context
private val adapter = RSS2FeedAdapter()
@Test
fun normalCasesTest() {
val stream = context.resources.assets.open("localfeed/rss2/rss_full_feed.xml")
val feed = adapter.fromXml(stream)
assertEquals(feed.name, "Hacker News")
assertEquals(feed.url, "https://news.ycombinator.com/feed/")
assertEquals(feed.siteUrl, "https://news.ycombinator.com/")
assertEquals(feed.description, "Links for the intellectually curious, ranked by readers.")
}
@Test(expected = ParseException::class)
fun nullTitleTest() {
val stream = context.resources.assets.open("localfeed/rss2/rss_feed_special_cases.xml")
adapter.fromXml(stream)
}
}

View File

@ -0,0 +1,95 @@
package com.readrops.api.localfeed.rss2
import android.content.Context
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.ParseException
import junit.framework.TestCase.*
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class RSS2ItemsAdapterTest {
private val context: Context = InstrumentationRegistry.getInstrumentation().context
private val adapter = RSS2ItemsAdapter()
@Test
fun normalCasesTest() {
val stream = context.resources.assets.open("localfeed/rss_feed.xml")
val items = adapter.fromXml(stream)
val item = items.first()
assertEquals(items.size, 7)
assertEquals(item.title, "Africa declared free of wild polio")
assertEquals(item.link, "https://www.bbc.com/news/world-africa-53887947")
assertEquals(item.pubDate, DateUtils.parse("Tue, 25 Aug 2020 17:15:49 +0000"))
assertEquals(item.author, "Author 1")
assertEquals(item.description, "<a href=\"https://news.ycombinator.com/item?id=24273602\">Comments</a>")
assertEquals(item.guid, "https://www.bbc.com/news/world-africa-53887947")
}
@Test
fun otherNamespacesTest() {
val stream = context.resources.assets.open("localfeed/rss2/rss_items_other_namespaces.xml")
val item = adapter.fromXml(stream).first()
assertEquals(item.guid, "guid")
assertEquals(item.author, "creator 1, creator 2, creator 3, creator 4")
assertEquals(item.pubDate, DateUtils.parse("2020-08-05T14:03:48Z"))
assertEquals(item.content, "content:encoded")
}
@Test
fun noDateTest() {
val stream = context.resources.assets.open("localfeed/rss2/rss_items_no_date.xml")
val item = adapter.fromXml(stream).first()
assertNotNull(item.pubDate)
}
@Test
fun noTitleTest() {
val stream = context.resources.assets.open("localfeed/rss2/rss_items_no_title.xml")
val exception = Assert.assertThrows(ParseException::class.java) { adapter.fromXml(stream) }
assertTrue(exception.message!!.contains("Item title is required"))
}
@Test
fun noLinkTest() {
val stream = context.resources.assets.open("localfeed/rss2/rss_items_no_link.xml")
val exception = Assert.assertThrows(ParseException::class.java) { adapter.fromXml(stream) }
assertTrue(exception.message!!.contains("Item link is required"))
}
@Test
fun enclosureTest() {
val stream = context.resources.assets.open("localfeed/rss2/rss_items_enclosure.xml")
val item = adapter.fromXml(stream).first()
assertEquals(item.imageLink, "https://image1.jpg")
}
@Test
fun mediaContentTest() {
val stream = context.resources.assets.open("localfeed/rss2/rss_items_media_content.xml")
val items = adapter.fromXml(stream)
assertEquals(items.first().imageLink, "https://image1.jpg")
assertEquals(items[1].imageLink, "https://image2.jpg")
}
@Test
fun mediaGroupTest() {
val stream = context.resources.assets.open("localfeed/rss2/rss_items_media_group.xml")
val item = adapter.fromXml(stream).first()
assertEquals(item.imageLink, "https://image1.jpg")
}
}

View File

@ -3,7 +3,10 @@
<!-- for tests only -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET" />
<application android:requestLegacyExternalStorage="true" />
<application
android:requestLegacyExternalStorage="true"
android:usesCleartextTraffic="true" />
</manifest>

View File

@ -1,9 +0,0 @@
package com.readrops.api.localfeed
/*
A simple class to give an abstract level to rss/atom/json feed classes
*/
abstract class AFeed {
var etag: String? = null
var lastModified: String? = null
}

View File

@ -0,0 +1,148 @@
package com.readrops.api.localfeed
import android.accounts.NetworkErrorException
import androidx.annotation.WorkerThread
import com.readrops.api.localfeed.json.JSONFeedAdapter
import com.readrops.api.localfeed.json.JSONItemsAdapter
import com.readrops.api.utils.LibUtils
import com.readrops.api.utils.ParseException
import com.readrops.api.utils.UnknownFormatException
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Item
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okio.Buffer
import java.io.ByteArrayInputStream
import java.io.IOException
import java.io.InputStream
import java.net.HttpURLConnection
class LocalRSSDataSource(private val httpClient: OkHttpClient) {
/**
* Query RSS url
* @param url url to query
* @param headers request headers
* @return a Feed object with its items
*/
@Throws(ParseException::class, UnknownFormatException::class, NetworkErrorException::class, IOException::class)
@WorkerThread
fun queryRSSResource(url: String, headers: Headers?): Pair<Feed, List<Item>>? {
val response = queryUrl(url, headers)
return when {
response.isSuccessful -> {
val header = response.header(LibUtils.CONTENT_TYPE_HEADER)
?: throw UnknownFormatException("Unable to get $url content-type")
val contentType = LibUtils.parseContentType(header)
?: throw ParseException("Unable to parse $url content-type")
var type = LocalRSSHelper.getRSSType(contentType)
val bodyArray = response.peekBody(Long.MAX_VALUE).bytes()
// if we can't guess type based on content-type header, we use the content
if (type == LocalRSSHelper.RSSType.UNKNOWN)
type = LocalRSSHelper.getRSSContentType(ByteArrayInputStream(bodyArray))
// if we can't guess type even with the content, we are unable to go further
if (type == LocalRSSHelper.RSSType.UNKNOWN) throw UnknownFormatException("Unable to guess $url RSS type")
val feed = parseFeed(ByteArrayInputStream(bodyArray), type, response)
val items = parseItems(ByteArrayInputStream(bodyArray), type)
response.body?.close()
Pair(feed, items)
}
response.code == HttpURLConnection.HTTP_NOT_MODIFIED -> null
else -> throw NetworkErrorException("$url returned ${response.code} code : ${response.message}")
}
}
/**
* Checks if the provided url is a RSS resource
* @param url url to check
* @return true if [url] is a RSS resource, false otherwise
*/
@WorkerThread
fun isUrlRSSResource(url: String): Boolean {
val response = queryUrl(url, null)
return if (response.isSuccessful) {
val header = response.header(LibUtils.CONTENT_TYPE_HEADER)
?: return false
val contentType = LibUtils.parseContentType(header)
?: return false
var type = LocalRSSHelper.getRSSType(contentType)
if (type == LocalRSSHelper.RSSType.UNKNOWN)
type = LocalRSSHelper.getRSSContentType(response.body?.byteStream()!!) // stream is closed in helper method
type != LocalRSSHelper.RSSType.UNKNOWN
} else false
}
@Throws(IOException::class)
private fun queryUrl(url: String, headers: Headers?): Response {
val requestBuilder = Request.Builder().url(url)
headers?.let { requestBuilder.headers(it) }
return httpClient.newCall(requestBuilder.build()).execute()
}
private fun parseFeed(stream: InputStream, type: LocalRSSHelper.RSSType, response: Response): Feed {
val feed = if (type != LocalRSSHelper.RSSType.JSONFEED) {
val adapter = XmlAdapter.xmlFeedAdapterFactory(type)
adapter.fromXml(stream)
} else {
val adapter = Moshi.Builder()
.add(JSONFeedAdapter())
.build()
.adapter(Feed::class.java)
adapter.fromJson(Buffer().readFrom(stream))!!
}
handleSpecialCases(feed, type, response)
feed.etag = response.header(LibUtils.ETAG_HEADER)
feed.lastModified = response.header(LibUtils.LAST_MODIFIED_HEADER)
return feed
}
private fun parseItems(stream: InputStream, type: LocalRSSHelper.RSSType): List<Item> {
return if (type != LocalRSSHelper.RSSType.JSONFEED) {
val adapter = XmlAdapter.xmlItemsAdapterFactory(type)
adapter.fromXml(stream)
} else {
val adapter = Moshi.Builder()
.add(Types.newParameterizedType(MutableList::class.java, Item::class.java), JSONItemsAdapter())
.build()
.adapter<List<Item>>(Types.newParameterizedType(MutableList::class.java, Item::class.java))
adapter.fromJson(Buffer().readFrom(stream))!!
}
}
private fun handleSpecialCases(feed: Feed, type: LocalRSSHelper.RSSType, response: Response) {
with(feed) {
if (type == LocalRSSHelper.RSSType.RSS_2) {
// if an atom:link element was parsed, we still replace its value as it is unreliable,
// otherwise we just add the rss url
url = response.request.url.toString()
} else if (type == LocalRSSHelper.RSSType.ATOM || type == LocalRSSHelper.RSSType.RSS_1) {
if (url == null) url = response.request.url.toString()
if (siteUrl == null) siteUrl = response.request.url.scheme + "://" + response.request.url.host
}
}
}
}

View File

@ -0,0 +1,61 @@
package com.readrops.api.localfeed
import java.io.InputStream
object LocalRSSHelper {
private const val RSS_1_CONTENT_TYPE = "application/rdf+xml"
private const val RSS_2_CONTENT_TYPE = "application/rss+xml"
private const val ATOM_CONTENT_TYPE = "application/atom+xml"
private const val JSONFEED_CONTENT_TYPE = "application/feed+json"
private const val JSON_CONTENT_TYPE = "application/json"
private const val RSS_1_REGEX = "<rdf:RDF.*xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\""
private const val RSS_2_REGEX = "rss.*version=\"2.0\""
private const val ATOM_REGEX = "<feed.* xmlns=\"http://www.w3.org/2005/Atom\""
/**
* Guess RSS type based on content-type header
*/
fun getRSSType(contentType: String): RSSType {
return when (contentType) {
RSS_1_CONTENT_TYPE -> RSSType.RSS_1
RSS_2_CONTENT_TYPE -> RSSType.RSS_2
ATOM_CONTENT_TYPE -> RSSType.ATOM
JSON_CONTENT_TYPE, JSONFEED_CONTENT_TYPE -> RSSType.JSONFEED
else -> RSSType.UNKNOWN
}
}
/**
* Guess RSS type based on xml content
*/
fun getRSSContentType(content: InputStream): RSSType {
val stringBuffer = StringBuffer()
val reader = content.bufferedReader()
// we get the first 10 lines which should be sufficient to get the type,
// otherwise iterating over the whole file could be too slow
for (i in 0..9) stringBuffer.append(reader.readLine())
val string = stringBuffer.toString()
val type = when {
RSS_1_REGEX.toRegex().containsMatchIn(string) -> RSSType.RSS_1
RSS_2_REGEX.toRegex().containsMatchIn(string) -> RSSType.RSS_2
ATOM_REGEX.toRegex().containsMatchIn(string) -> RSSType.ATOM
else -> RSSType.UNKNOWN
}
reader.close()
content.close()
return type
}
enum class RSSType {
RSS_1,
RSS_2,
ATOM,
JSONFEED,
UNKNOWN
}
}

View File

@ -1,197 +0,0 @@
package com.readrops.api.localfeed;
import android.accounts.NetworkErrorException;
import android.util.Log;
import com.readrops.api.localfeed.atom.ATOMFeed;
import com.readrops.api.localfeed.json.JSONFeed;
import com.readrops.api.localfeed.rss.RSSFeed;
import com.readrops.api.localfeed.rss.RSSLink;
import com.readrops.api.utils.HttpManager;
import com.readrops.api.utils.LibUtils;
import com.readrops.api.utils.UnknownFormatException;
import com.squareup.moshi.JsonAdapter;
import com.squareup.moshi.Moshi;
import org.simpleframework.xml.Serializer;
import org.simpleframework.xml.core.Persister;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
public class RSSQuery {
private static final String TAG = RSSQuery.class.getSimpleName();
private static final String RSS_CONTENT_TYPE_REGEX = "([^;]+)";
private static final String RSS_2_REGEX = "rss.*version=\"2.0\"";
private static final String ATOM_REGEX = "<feed.* xmlns=\"http://www.w3.org/2005/Atom\"";
/**
* Request the url given in parameter.
* This method is synchronous, it <b>has</b> to be called from another thread than the main one.
*
* @param url url to request
* @throws Exception
*/
public RSSQueryResult queryUrl(String url, Map<String, String> headers) throws Exception {
Response response = query(url, headers);
if (response.isSuccessful()) {
String header = response.header(LibUtils.CONTENT_TYPE_HEADER);
RSSType type = getRSSType(header);
if (type == null)
return new RSSQueryResult(new UnknownFormatException("bad content type : " + header + "for " + url));
return parseFeed(response.body().byteStream(), type, response);
} else if (response.code() == 304)
return null;
else
return new RSSQueryResult(new NetworkErrorException("Error " + response.code() + " when requesting url " + url));
}
public boolean isUrlFeedLink(String url) throws IOException {
Response response = query(url, new HashMap<>());
if (response.isSuccessful()) {
String header = response.header(LibUtils.CONTENT_TYPE_HEADER);
RSSType type = getRSSType(header);
if (type == RSSType.RSS_UNKNOWN) {
RSSType contentType = getContentRSSType(response.body().string());
return contentType != RSSType.RSS_UNKNOWN;
} else return true;
} else
return false;
}
private Response query(String url, Map<String, String> headers) throws IOException {
OkHttpClient okHttpClient = HttpManager.getInstance().getOkHttpClient();
HttpManager.getInstance().setCredentials(null);
Request.Builder builder = new Request.Builder().url(url);
for (String header : headers.keySet()) {
String value = headers.get(header);
builder.addHeader(header, value);
}
Request request = builder.build();
return okHttpClient.newCall(request).execute();
}
private RSSType getRSSType(String contentType) {
Pattern pattern = Pattern.compile(RSS_CONTENT_TYPE_REGEX);
Matcher matcher = pattern.matcher(contentType);
String header;
if (matcher.find())
header = matcher.group(0);
else
header = contentType;
switch (header) {
case LibUtils.RSS_DEFAULT_CONTENT_TYPE:
return RSSType.RSS_2;
case LibUtils.ATOM_CONTENT_TYPE:
return RSSType.RSS_ATOM;
case LibUtils.JSON_CONTENT_TYPE:
return RSSType.RSS_JSON;
case LibUtils.RSS_TEXT_CONTENT_TYPE:
case LibUtils.HTML_CONTENT_TYPE:
case LibUtils.RSS_APPLICATION_CONTENT_TYPE:
default:
Log.d(TAG, "bad content type : " + contentType);
return RSSType.RSS_UNKNOWN;
}
}
/**
* Parse input feed
*
* @param stream source to parse
* @param type rss type, important to know the feed format
* @param response query response
* @throws Exception
*/
private RSSQueryResult parseFeed(InputStream stream, RSSType type, Response response) throws Exception {
String xml = LibUtils.inputStreamToString(stream);
Serializer serializer = new Persister();
if (type == RSSType.RSS_UNKNOWN) {
RSSType contentType = getContentRSSType(xml);
if (contentType == RSSType.RSS_UNKNOWN) {
return new RSSQueryResult(new UnknownFormatException("Unknown content format"));
} else
type = contentType;
}
String etag = response.header(LibUtils.ETAG_HEADER);
String lastModified = response.header(LibUtils.LAST_MODIFIED_HEADER);
AFeed feed = null;
RSSQueryResult queryResult = new RSSQueryResult();
switch (type) {
case RSS_2:
feed = serializer.read(RSSFeed.class, xml);
// workaround if the channel does not have any atom:link tag
if (((RSSFeed) feed).getChannel().getFeedUrl() == null) {
((RSSFeed) feed).getChannel().getLinks().add(new RSSLink(null, response.request().url().toString()));
}
break;
case RSS_ATOM:
feed = serializer.read(ATOMFeed.class, xml);
((ATOMFeed) feed).setWebsiteUrl(response.request().url().scheme() + "://" + response.request().url().host());
((ATOMFeed) feed).setUrl(response.request().url().toString());
break;
case RSS_JSON:
Moshi moshi = new Moshi.Builder()
.build();
JsonAdapter<JSONFeed> jsonFeedAdapter = moshi.adapter(JSONFeed.class);
feed = jsonFeedAdapter.fromJson(xml);
break;
}
queryResult.setFeed(feed);
queryResult.setRssType(type);
feed.setEtag(etag);
feed.setLastModified(lastModified);
return queryResult;
}
private RSSType getContentRSSType(String content) {
RSSType type;
if (Pattern.compile(RSS_2_REGEX).matcher(content).find())
type = RSSType.RSS_2;
else if (Pattern.compile(ATOM_REGEX).matcher(content).find())
type = RSSType.RSS_ATOM;
else
type = RSSType.RSS_UNKNOWN;
return type;
}
public enum RSSType {
RSS_2,
RSS_ATOM,
RSS_JSON,
RSS_UNKNOWN
}
}

View File

@ -1,42 +0,0 @@
package com.readrops.api.localfeed;
public class RSSQueryResult {
private AFeed feed;
private RSSQuery.RSSType rssType;
private Exception exception;
public RSSQueryResult(Exception exception) {
this.exception = exception;
}
public RSSQueryResult() {
}
public AFeed getFeed() {
return feed;
}
public void setFeed(AFeed feed) {
this.feed = feed;
}
public RSSQuery.RSSType getRssType() {
return rssType;
}
public void setRssType(RSSQuery.RSSType rssType) {
this.rssType = rssType;
}
public void setException(Exception exception) {
this.exception = exception;
}
public Exception getException() {
return exception;
}
}

View File

@ -0,0 +1,39 @@
package com.readrops.api.localfeed
import com.readrops.api.localfeed.atom.ATOMFeedAdapter
import com.readrops.api.localfeed.atom.ATOMItemsAdapter
import com.readrops.api.localfeed.rss1.RSS1FeedAdapter
import com.readrops.api.localfeed.rss1.RSS1ItemsAdapter
import com.readrops.api.localfeed.rss2.RSS2FeedAdapter
import com.readrops.api.localfeed.rss2.RSS2ItemsAdapter
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Item
import java.io.InputStream
interface XmlAdapter<T> {
fun fromXml(inputStream: InputStream): T
companion object {
fun xmlFeedAdapterFactory(type: LocalRSSHelper.RSSType): XmlAdapter<Feed> {
return when (type) {
LocalRSSHelper.RSSType.RSS_1 -> RSS1FeedAdapter()
LocalRSSHelper.RSSType.RSS_2 -> RSS2FeedAdapter()
LocalRSSHelper.RSSType.ATOM -> ATOMFeedAdapter()
else -> throw IllegalArgumentException("Unknown RSS type : $type")
}
}
fun xmlItemsAdapterFactory(type: LocalRSSHelper.RSSType): XmlAdapter<List<Item>> {
return when (type) {
LocalRSSHelper.RSSType.RSS_1 -> RSS1ItemsAdapter()
LocalRSSHelper.RSSType.RSS_2 -> RSS2ItemsAdapter()
LocalRSSHelper.RSSType.ATOM -> ATOMItemsAdapter()
else -> throw IllegalArgumentException("Unknown RSS type : $type")
}
}
const val AUTHORS_MAX = 4
}
}

View File

@ -1,22 +0,0 @@
package com.readrops.api.localfeed.atom;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.Root;
@Root(name = "author", strict = false)
public class ATOMAuthor {
@Element(required = false)
private String name;
@Element(required = false)
private String email;
public String getName() {
return name;
}
public String getEmail() {
return email;
}
}

View File

@ -1,98 +0,0 @@
package com.readrops.api.localfeed.atom;
import org.simpleframework.xml.Attribute;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.ElementList;
import org.simpleframework.xml.Root;
import java.util.List;
@Root(name = "entry", strict = false)
public class ATOMEntry {
@Element(required = false)
private String title;
@ElementList(name = "link", inline = true, required = false)
private List<ATOMLink> links;
@Element(required = false)
private String updated;
@Element(required = false)
private String summary;
@Element(required = false)
private String id;
@Element(required = false)
private String content;
@Attribute(name = "type", required = false)
private String contentType;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public List<ATOMLink> getLinks() {
return links;
}
public void setLinks(List<ATOMLink> links) {
this.links = links;
}
public String getUpdated() {
return updated;
}
public void setUpdated(String updated) {
this.updated = updated;
}
public String getSummary() {
return summary;
}
public void setSummary(String summary) {
this.summary = summary;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public String getUrl() {
for (ATOMLink link : links) {
if (link.getRel() == null || link.getRel().equals("self") || link.getRel().equals("alternate"))
return link.getHref();
}
return null;
}
}

View File

@ -1,128 +0,0 @@
package com.readrops.api.localfeed.atom;
import com.readrops.api.localfeed.AFeed;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.ElementList;
import org.simpleframework.xml.Root;
import java.util.List;
@Root(name = "feed", strict = false)
public class ATOMFeed extends AFeed {
@Element(required = false)
private String title;
@ElementList(name = "link", inline = true, required = false)
private List<ATOMLink> links;
private String url;
private String websiteUrl;
@Element(required = false)
private String id;
@Element(required = false)
private String subtitle;
@Element(required = false)
private String updated;
@Element(required = false)
private ATOMAuthor author;
@ElementList(inline = true, required = false)
private List<ATOMEntry> entries;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public List<ATOMLink> getLinks() {
return links;
}
public void setLinks(List<ATOMLink> links) {
this.links = links;
}
public String getSubtitle() {
return subtitle;
}
public void setSubtitle(String subtitle) {
this.subtitle = subtitle;
}
public String getUpdated() {
return updated;
}
public void setUpdated(String updated) {
this.updated = updated;
}
public ATOMAuthor getAuthor() {
return author;
}
public void setAuthor(ATOMAuthor author) {
this.author = author;
}
public List<ATOMEntry> getEntries() {
return entries;
}
public void setEntries(List<ATOMEntry> entries) {
this.entries = entries;
}
public String getId() {
return id;
}
public void setId(String id) {
this.id = id;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getWebsiteUrl() {
return websiteUrl;
}
public void setWebsiteUrl(String websiteUrl) {
this.websiteUrl = websiteUrl;
}
/*public String getWebSiteUrl() {
return id;
}
public String getUrl() {
if (links.size() > 0) {
if (links.get(0).getRel() != null)
return links.get(0).getHref();
else {
if (links.size() > 1)
return links.get(1).getHref();
else
return null;
}
} else
return null;
}*/
}

View File

@ -0,0 +1,52 @@
package com.readrops.api.localfeed.atom
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.utils.ParseException
import com.readrops.api.utils.nonNullText
import com.readrops.api.utils.nullableText
import com.readrops.db.entities.Feed
import java.io.InputStream
class ATOMFeedAdapter : XmlAdapter<Feed> {
override fun fromXml(inputStream: InputStream): Feed {
val konsume = inputStream.konsumeXml()
val feed = Feed()
return try {
konsume.child("feed") {
allChildrenAutoIgnore(names) {
with(feed) {
when (tagName) {
"title" -> name = nonNullText()
"link" -> parseLink(this@allChildrenAutoIgnore, feed)
"subtitle" -> description = nullableText()
}
}
}
}
konsume.close()
feed
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseLink(konsume: Konsumer, feed: Feed) {
val rel = konsume.attributes.getValueOpt("rel")
if (rel == "self")
feed.url = konsume.attributes["href"]
else if (rel == "alternate")
feed.siteUrl = konsume.attributes["href"]
}
companion object {
val names = Names.of("title", "link", "subtitle")
}
}

View File

@ -0,0 +1,71 @@
package com.readrops.api.localfeed.atom
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.utils.*
import com.readrops.db.entities.Item
import org.joda.time.LocalDateTime
import java.io.InputStream
class ATOMItemsAdapter : XmlAdapter<List<Item>> {
override fun fromXml(inputStream: InputStream): List<Item> {
val konsumer = inputStream.konsumeXml()
val items = arrayListOf<Item>()
return try {
konsumer.child("feed") {
allChildrenAutoIgnore("entry") {
val item = Item().apply {
allChildrenAutoIgnore(names) {
when (tagName) {
"title" -> title = nonNullText()
"id" -> guid = nullableText()
"updated" -> pubDate = DateUtils.parse(nullableText())
"link" -> parseLink(this, this@apply)
"author" -> allChildrenAutoIgnore("name") { author = nullableText() }
"summary" -> description = nullableTextRecursively()
"content" -> content = nullableTextRecursively()
else -> skipContents()
}
}
}
validateItem(item)
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
if (item.guid == null) item.guid = item.link
items += item
}
}
konsumer.close()
items
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseLink(konsumer: Konsumer, item: Item) {
konsumer.apply {
if (attributes.getValueOpt("rel") == null ||
attributes["rel"] == "alternate")
item.link = attributes.getValueOpt("href")
}
}
private fun validateItem(item: Item) {
when {
item.title == null -> throw ParseException("Item title is required")
item.link == null -> throw ParseException("Item link is required")
}
}
companion object {
val names = Names.of("title", "id", "updated", "link", "author", "summary", "content")
}
}

View File

@ -1,30 +0,0 @@
package com.readrops.api.localfeed.atom;
import org.simpleframework.xml.Attribute;
import org.simpleframework.xml.Root;
@Root(name = "link", strict = false)
public class ATOMLink {
@Attribute(name = "href", required = false)
private String href;
@Attribute(name = "rel", required = false)
private String rel;
public String getHref() {
return href;
}
public void setHref(String href) {
this.href = href;
}
public String getRel() {
return rel;
}
public void setRel(String rel) {
this.rel = rel;
}
}

View File

@ -1,9 +0,0 @@
package com.readrops.api.localfeed.json
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class JSONAuthor(val name: String,
val url: String,
@Json(name = "avatar") val avatarUrl: String?)

View File

@ -1,15 +0,0 @@
package com.readrops.api.localfeed.json
import com.readrops.api.localfeed.AFeed
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class JSONFeed(val version: String,
val title: String,
@Json(name = "home_page_url") val homePageUrl: String?,
@Json(name = "feed_url") val feedUrl: String?,
val description: String?,
@Json(name = "icon") val iconUrl: String?,
@Json(name = "favicon") val faviconUrl: String?,
val items: List<JSONItem>) : AFeed()

View File

@ -0,0 +1,45 @@
package com.readrops.api.localfeed.json
import com.readrops.api.utils.ParseException
import com.readrops.api.utils.nextNonEmptyString
import com.readrops.api.utils.nextNullableString
import com.readrops.db.entities.Feed
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class JSONFeedAdapter {
@ToJson
fun toJson(feed: Feed) = ""
@FromJson
fun fromJson(reader: JsonReader): Feed {
return try {
val feed = Feed()
reader.beginObject()
while (reader.hasNext()) {
with(feed) {
when (reader.selectName(names)) {
0 -> name = reader.nextNonEmptyString()
1 -> siteUrl = reader.nextNullableString()
2 -> url = reader.nextNullableString()
3 -> description = reader.nextNullableString()
else -> reader.skipValue()
}
}
}
reader.endObject()
feed
} catch (e: Exception) {
throw ParseException(e.message)
}
}
companion object {
val names: JsonReader.Options = JsonReader.Options.of("title", "home_page_url",
"feed_url", "description")
}
}

View File

@ -1,21 +0,0 @@
package com.readrops.api.localfeed.json
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
data class JSONItem(val id: String,
val title: String?,
val summary: String?,
@Json(name = "content_text") val contentText: String?,
@Json(name = "content_html") val contentHtml: String?,
val url: String?,
@Json(name = "image") val imageUrl: String?,
@Json(name = "date_published") val pubDate: String,
@Json(name = "date_modified") val modDate: String?,
val author: JSONAuthor?) {
fun getContent(): String? {
return contentHtml ?: contentText
}
}

View File

@ -0,0 +1,117 @@
package com.readrops.api.localfeed.json
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.ParseException
import com.readrops.api.utils.nextNonEmptyString
import com.readrops.api.utils.nextNullableString
import com.readrops.db.entities.Item
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import org.joda.time.LocalDateTime
class JSONItemsAdapter : JsonAdapter<List<Item>>() {
override fun toJson(writer: JsonWriter, value: List<Item>?) {
// not useful
}
override fun fromJson(reader: JsonReader): List<Item> {
return try {
val items = arrayListOf<Item>()
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"items" -> parseItems(reader, items)
else -> reader.skipValue()
}
}
items
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseItems(reader: JsonReader, items: MutableList<Item>) {
reader.beginArray()
while (reader.hasNext()) {
reader.beginObject()
val item = Item()
var contentText: String? = null
var contentHtml: String? = null
while (reader.hasNext()) {
with(item) {
when (reader.selectName(names)) {
0 -> guid = reader.nextNonEmptyString()
1 -> link = reader.nextNonEmptyString()
2 -> title = reader.nextNonEmptyString()
3 -> contentHtml = reader.nextNullableString()
4 -> contentText = reader.nextNullableString()
5 -> description = reader.nextNullableString()
6 -> imageLink = reader.nextNullableString()
7 -> pubDate = DateUtils.parse(reader.nextNullableString())
8 -> author = parseAuthor(reader) // jsonfeed 1.0
9 -> author = parseAuthors(reader) // jsonfeed 1.1
else -> reader.skipValue()
}
}
}
validateItem(item)
item.content = if (contentHtml != null) contentHtml else contentText
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
reader.endObject()
items += item
}
reader.endArray()
}
private fun parseAuthor(reader: JsonReader): String? {
var author: String? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"name" -> author = reader.nextNullableString()
else -> reader.skipValue()
}
}
reader.endObject()
return author
}
private fun parseAuthors(reader: JsonReader): String? {
val authors = arrayListOf<String?>()
reader.beginArray()
while (reader.hasNext()) {
authors.add(parseAuthor(reader))
}
reader.endArray()
return if (authors.filterNotNull().isNotEmpty())
authors.filterNotNull().joinToString(limit = AUTHORS_MAX) else null
}
private fun validateItem(item: Item) {
when {
item.title == null -> throw ParseException("Item title is required")
item.link == null -> throw ParseException("Item link is required")
}
}
companion object {
val names: JsonReader.Options = JsonReader.Options.of("id", "url", "title",
"content_html", "content_text", "summary", "image", "date_published", "author", "authors")
}
}

View File

@ -1,87 +0,0 @@
package com.readrops.api.localfeed.rss;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.ElementList;
import org.simpleframework.xml.Root;
import java.util.List;
@Root(name = "channel", strict = false)
public class RSSChannel {
@Element(name = "title", required = false)
private String title;
@Element(name = "description", required = false)
private String description;
// workaround to get the two links (feed and regular)
@ElementList(name = "link", inline = true, required = false)
private List<RSSLink> links;
@Element(name = "lastBuildDate", required = false)
private String lastUpdated;
@ElementList(inline = true, required = false)
private List<RSSItem> items;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public List<RSSLink> getLinks() {
return links;
}
public void setLinks(List<RSSLink> links) {
this.links = links;
}
public List<RSSItem> getItems() {
return items;
}
public void setItems(List<RSSItem> items) {
this.items = items;
}
public String getLastUpdated() {
return lastUpdated;
}
public void setLastUpdated(String lastUpdated) {
this.lastUpdated = lastUpdated;
}
public String getFeedUrl() {
if (links.size() > 1) {
if (links.get(0).getHref() != null)
return links.get(0).getHref();
else
return links.get(1).getHref();
} else
return null;
}
public String getUrl() {
if (links.size() > 1) {
if (links.get(1).getText() != null)
return links.get(1).getText();
else
return links.get(0).getText();
} else
return null;
}
}

View File

@ -1,30 +0,0 @@
package com.readrops.api.localfeed.rss;
import org.simpleframework.xml.Attribute;
import org.simpleframework.xml.Root;
@Root(name = "enclosure", strict = false)
public class RSSEnclosure {
@Attribute(required = false)
private String type;
@Attribute(required = false)
private String url;
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
}

View File

@ -1,21 +0,0 @@
package com.readrops.api.localfeed.rss;
import com.readrops.api.localfeed.AFeed;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.Root;
@Root(name = "rss", strict = false)
public class RSSFeed extends AFeed {
@Element(name = "channel", required = false)
private RSSChannel channel;
public RSSChannel getChannel() {
return channel;
}
public void setChannel(RSSChannel channel) {
this.channel = channel;
}
}

View File

@ -1,154 +0,0 @@
package com.readrops.api.localfeed.rss;
import org.simpleframework.xml.Element;
import org.simpleframework.xml.ElementList;
import org.simpleframework.xml.Namespace;
import org.simpleframework.xml.Root;
import java.util.List;
@Root(name = "item", strict = false)
public class RSSItem {
@Element
private String title;
@Element(name = "link", required = false)
private String link;
@Element(name = "imageLink", required = false)
private String imageLink;
@ElementList(name = "content", inline = true, required = false)
@Namespace(prefix = "media")
private List<RSSMediaContent> mediaContents;
@ElementList(name = "enclosure", inline = true, required = false)
private List<RSSEnclosure> enclosures;
@ElementList(name = "creator", inline = true, required = false)
@Namespace(prefix = "dc", reference = "http://purl.org/dc/elements/1.1/")
private List<String> creator;
@Element(required = false)
private String author;
@Element(name = "pubDate", required = false)
private String pubDate;
@Element(name = "date", required = false)
@Namespace(prefix = "dc")
private String date;
@Element(name = "description", required = false)
private String description;
@Element(name = "encoded", required = false)
@Namespace(prefix = "content")
private String content;
@Element(required = false)
private String guid;
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public String getLink() {
return link;
}
public void setLink(String link) {
this.link = link;
}
public String getImageLink() {
return imageLink;
}
public void setImageLink(String imageLink) {
this.imageLink = imageLink;
}
public List<String> getCreator() {
return creator;
}
public void setCreator(List<String> creator) {
this.creator = creator;
}
public String getPubDate() {
return pubDate;
}
public void setPubDate(String pubDate) {
this.pubDate = pubDate;
}
public String getContent() {
return content;
}
public void setContent(String content) {
this.content = content;
}
public String getGuid() {
return guid;
}
public void setGuid(String guid) {
this.guid = guid;
}
public List<RSSMediaContent> getMediaContents() {
return mediaContents;
}
public void setMediaContents(List<RSSMediaContent> mediaContents) {
this.mediaContents = mediaContents;
}
public List<RSSEnclosure> getEnclosures() {
return enclosures;
}
public void setEnclosures(List<RSSEnclosure> enclosures) {
this.enclosures = enclosures;
}
public String getDate() {
if (pubDate != null)
return pubDate;
else
return date;
}
public void setDate(String date) {
this.date = date;
}
public String getAuthor() {
if (creator != null && !creator.isEmpty())
return creator.get(0);
else
return author;
}
public void setAuthor(String author) {
this.author = author;
}
}

View File

@ -1,40 +0,0 @@
package com.readrops.api.localfeed.rss;
import org.simpleframework.xml.Attribute;
import org.simpleframework.xml.Root;
import org.simpleframework.xml.Text;
@Root(name = "link", strict = false)
public class RSSLink {
@Text(required = false)
private String text;
@Attribute(name = "href", required = false)
private String href;
public RSSLink() {
}
public RSSLink(String text, String href) {
this.text = text;
this.href = href;
}
public String getHref() {
return href;
}
public void setHref(String href) {
this.href = href;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
}

View File

@ -1,30 +0,0 @@
package com.readrops.api.localfeed.rss;
import org.simpleframework.xml.Attribute;
import org.simpleframework.xml.Root;
@Root(name = "content", strict = false)
public class RSSMediaContent {
@Attribute(required = false)
private String url;
@Attribute(required = false)
private String medium;
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getMedium() {
return medium;
}
public void setMedium(String medium) {
this.medium = medium;
}
}

View File

@ -0,0 +1,48 @@
package com.readrops.api.localfeed.rss1
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.utils.ParseException
import com.readrops.api.utils.nonNullText
import com.readrops.api.utils.nullableText
import com.readrops.db.entities.Feed
import java.io.InputStream
class RSS1FeedAdapter : XmlAdapter<Feed> {
override fun fromXml(inputStream: InputStream): Feed {
val konsume = inputStream.konsumeXml()
val feed = Feed()
return try {
konsume.child("RDF") {
allChildrenAutoIgnore("channel") {
feed.url = attributes.getValueOpt("about",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
allChildrenAutoIgnore(names) {
with(feed) {
when (tagName) {
"title" -> name = nonNullText()
"link" -> siteUrl = nonNullText()
"description" -> description = nullableText()
}
}
}
}
}
konsume.close()
feed
} catch (e: Exception) {
throw ParseException(e.message)
}
}
companion object {
val names = Names.of("title", "link", "description")
}
}

View File

@ -0,0 +1,68 @@
package com.readrops.api.localfeed.rss1
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
import com.readrops.api.utils.*
import com.readrops.db.entities.Item
import org.joda.time.LocalDateTime
import java.io.InputStream
class RSS1ItemsAdapter : XmlAdapter<List<Item>> {
override fun fromXml(inputStream: InputStream): List<Item> {
val konsumer = inputStream.konsumeXml()
val items = arrayListOf<Item>()
return try {
konsumer.child("RDF") {
allChildrenAutoIgnore("item") {
val authors = arrayListOf<String?>()
val about = attributes.getValueOpt("about",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
val item = Item().apply {
allChildrenAutoIgnore(names) {
when (tagName) {
"title" -> title = nonNullText()
"link" -> link = nullableText()
"dc:date" -> pubDate = DateUtils.parse(nullableText())
"dc:creator" -> authors += nullableText()
"description" -> description = nullableTextRecursively()
"content:encoded" -> content = nullableTextRecursively()
else -> skipContents()
}
}
}
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
if (item.link == null) item.link = about
?: throw ParseException("RSS1 link or about element is required")
item.guid = item.link
if (authors.filterNotNull().isNotEmpty()) item.author = authors.filterNotNull()
.joinToString(limit = AUTHORS_MAX)
validateItem(item)
items += item
}
}
konsumer.close()
items
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun validateItem(item: Item) {
if (item.title == null) throw ParseException("Item title is required")
}
companion object {
val names = Names.of("title", "description", "date", "link", "creator", "encoded")
}
}

View File

@ -0,0 +1,50 @@
package com.readrops.api.localfeed.rss2
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.utils.ParseException
import com.readrops.api.utils.nonNullText
import com.readrops.api.utils.nullableText
import com.readrops.db.entities.Feed
import org.jsoup.Jsoup
import java.io.InputStream
class RSS2FeedAdapter : XmlAdapter<Feed> {
override fun fromXml(inputStream: InputStream): Feed {
val konsume = inputStream.konsumeXml()
val feed = Feed()
return try {
konsume.child("rss") {
child("channel") {
allChildrenAutoIgnore(names) {
with(feed) {
when (tagName) {
"title" -> name = Jsoup.parse(nonNullText()).text()
"description" -> description = nullableText()
"link" -> siteUrl = nullableText()
"atom:link" -> {
if (attributes.getValueOpt("rel") == "self")
url = attributes.getValueOpt("href")
}
else -> skipContents()
}
}
}
}
}
konsume.close()
feed
} catch (e: Exception) {
throw ParseException(e.message)
}
}
companion object {
val names = Names.of("title", "description", "link")
}
}

View File

@ -0,0 +1,109 @@
package com.readrops.api.localfeed.rss2
import com.gitlab.mvysny.konsumexml.*
import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
import com.readrops.api.utils.*
import com.readrops.db.entities.Item
import org.joda.time.LocalDateTime
import java.io.InputStream
class RSS2ItemsAdapter : XmlAdapter<List<Item>> {
override fun fromXml(inputStream: InputStream): List<Item> {
val konsumer = inputStream.konsumeXml()
val items = mutableListOf<Item>()
return try {
konsumer.child("rss") {
child("channel") {
allChildrenAutoIgnore("item") {
val creators = arrayListOf<String?>()
val item = Item().apply {
allChildrenAutoIgnore(names) {
when (tagName) {
"title" -> title = LibUtils.cleanText(nonNullText())
"link" -> link = nonNullText()
"author" -> author = nullableText()
"dc:creator" -> creators += nullableText()
"pubDate" -> pubDate = DateUtils.parse(nullableText())
"dc:date" -> pubDate = DateUtils.parse(nullableText())
"guid" -> guid = nullableText()
"description" -> description = nullableTextRecursively()
"content:encoded" -> content = nullableTextRecursively()
"enclosure" -> parseEnclosure(this, item = this@apply)
"media:content" -> parseMediaContent(this, item = this@apply)
"media:group" -> parseMediaGroup(this, item = this@apply)
else -> skipContents() // for example media:description
}
}
}
finalizeItem(item, creators)
items += item
}
}
}
konsumer.close()
items
} catch (e: KonsumerException) {
throw ParseException(e.message)
}
}
private fun parseEnclosure(konsumer: Konsumer, item: Item) {
if (konsumer.attributes.getValueOpt("type") != null
&& LibUtils.isMimeImage(konsumer.attributes["type"]) && item.imageLink == null)
item.imageLink = konsumer.attributes.getValueOpt("url")
}
private fun isMediumImage(konsumer: Konsumer) = with(konsumer) {
attributes.getValueOpt("medium") != null && LibUtils.isMimeImage(attributes["medium"])
}
private fun isTypeImage(konsumer: Konsumer) = with(konsumer) {
attributes.getValueOpt("type") != null && LibUtils.isMimeImage(attributes["type"])
}
private fun parseMediaContent(konsumer: Konsumer, item: Item) {
if ((isMediumImage(konsumer) || isTypeImage(konsumer)) && item.imageLink == null)
item.imageLink = konsumer.attributes.getValueOpt("url")
konsumer.skipContents() // ignore media content sub elements
}
private fun parseMediaGroup(konsumer: Konsumer, item: Item) {
konsumer.allChildrenAutoIgnore("content") {
when (tagName) {
"media:content" -> parseMediaContent(this, item)
else -> skipContents()
}
}
}
private fun finalizeItem(item: Item, creators: List<String?>) {
item.apply {
validateItem(this)
if (pubDate == null) pubDate = LocalDateTime.now()
if (guid == null) guid = link
if (author == null && creators.filterNotNull().isNotEmpty())
author = creators.filterNotNull().joinToString(limit = AUTHORS_MAX)
}
}
private fun validateItem(item: Item) {
when {
item.title == null -> throw ParseException("Item title is required")
item.link == null -> throw ParseException("Item link is required")
}
}
companion object {
val names = Names.of("title", "link", "author", "creator", "pubDate", "date",
"guid", "description", "encoded", "enclosure", "content", "group")
}
}

View File

@ -0,0 +1,76 @@
package com.readrops.api.utils;
import android.util.Log;
import androidx.annotation.Nullable;
import org.joda.time.LocalDateTime;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.format.DateTimeFormatterBuilder;
import java.util.Locale;
public final class DateUtils {
private static final String TAG = DateUtils.class.getSimpleName();
/**
* Base of common RSS 2 date formats.
* Examples :
* Fri, 04 Jan 2019 22:21:46 GMT
* Fri, 04 Jan 2019 22:21:46 +0000
*/
private static final String RSS_2_BASE_PATTERN = "EEE, dd MMM yyyy HH:mm:ss";
private static final String GMT_PATTERN = "ZZZ";
private static final String OFFSET_PATTERN = "Z";
private static final String ISO_PATTERN = ".SSSZZ";
private static final String EDT_PATTERN = "zzz";
/**
* Date pattern for format : 2019-01-04T22:21:46+00:00
*/
private static final String ATOM_JSON_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
@Nullable
public static LocalDateTime parse(String value) {
if (value == null) {
return null;
}
try {
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN + " ").getParser()) // with timezone
.appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).getParser()) // no timezone, important order here
.appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).getParser())
.appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).getParser())
.appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).getParser())
.appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).getParser())
.appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).getParser())
.toFormatter()
.withLocale(Locale.ENGLISH)
.withOffsetParsed();
return formatter.parseLocalDateTime(value);
} catch (Exception e) {
Log.d(TAG, e.getMessage());
return null;
}
}
public static String formattedDateByLocal(LocalDateTime dateTime) {
return DateTimeFormat.mediumDate()
.withLocale(Locale.getDefault())
.print(dateTime);
}
public static String formattedDateTimeByLocal(LocalDateTime dateTime) {
return DateTimeFormat.forPattern("dd MMM yyyy · HH:mm")
.withLocale(Locale.getDefault())
.print(dateTime);
}
}

View File

@ -3,4 +3,9 @@ package com.readrops.api.utils
import com.squareup.moshi.JsonReader
fun JsonReader.nextNullableString(): String? =
if (peek() != JsonReader.Token.NULL) nextString() else nextNull()
if (peek() != JsonReader.Token.NULL) nextString().ifEmpty { null }?.trim() else nextNull()
fun JsonReader.nextNonEmptyString(): String {
val text = nextString()
return if (text.isNotEmpty()) text.trim() else throw ParseException("Json value can't be null")
}

View File

@ -0,0 +1,20 @@
package com.readrops.api.utils
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.Whitespace
import com.gitlab.mvysny.konsumexml.textRecursively
fun Konsumer.nonNullText(): String {
val text = text(whitespace = Whitespace.preserve)
return if (text.isNotEmpty()) text.trim() else throw ParseException("$name text can't be null")
}
fun Konsumer.nullableText(): String? {
val text = text(whitespace = Whitespace.preserve)
return if (text.isNotEmpty()) text.trim() else null
}
fun Konsumer.nullableTextRecursively(): String? {
val text = textRecursively()
return if (text.isNotEmpty()) text.trim() else null
}

View File

@ -4,10 +4,15 @@ import android.content.Context;
import android.net.Uri;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.jsoup.Jsoup;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.util.Scanner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class LibUtils {
@ -28,6 +33,8 @@ public final class LibUtils {
public static final int HTTP_NOT_FOUND = 404;
public static final int HTTP_CONFLICT = 409;
private static final String RSS_CONTENT_TYPE_REGEX = "([^;]+)";
public static String inputStreamToString(InputStream input) {
Scanner scanner = new Scanner(input).useDelimiter("\\A");
@ -44,4 +51,26 @@ public final class LibUtils {
return type.equals("image") || type.equals("image/jpeg") || type.equals("image/jpg")
|| type.equals("image/png");
}
@Nullable
public static String parseContentType(String header) {
Matcher matcher = Pattern.compile(RSS_CONTENT_TYPE_REGEX)
.matcher(header);
if (matcher.find()) {
return matcher.group(0);
} else {
return null;
}
}
/**
* Remove html tags and trim the text
*
* @param text string to clean
* @return cleaned text
*/
public static String cleanText(String text) {
return Jsoup.parse(text).text().trim();
}
}

View File

@ -0,0 +1,79 @@
package com.readrops.api.localfeed
import junit.framework.TestCase.assertEquals
import org.junit.Test
import java.io.ByteArrayInputStream
class LocalRSSHelperTest {
@Test
fun standardContentTypesTest() {
assertEquals(LocalRSSHelper.getRSSType("application/rdf+xml"),
LocalRSSHelper.RSSType.RSS_1)
assertEquals(LocalRSSHelper.getRSSType("application/rss+xml"),
LocalRSSHelper.RSSType.RSS_2)
assertEquals(LocalRSSHelper.getRSSType("application/atom+xml"),
LocalRSSHelper.RSSType.ATOM)
assertEquals(LocalRSSHelper.getRSSType("application/json"),
LocalRSSHelper.RSSType.JSONFEED)
assertEquals(LocalRSSHelper.getRSSType("application/feed+json"),
LocalRSSHelper.RSSType.JSONFEED)
}
@Test
fun nonSupportedContentTypesTest() {
assertEquals(LocalRSSHelper.getRSSType("application/xml"),
LocalRSSHelper.RSSType.UNKNOWN)
assertEquals(LocalRSSHelper.getRSSType("text/xml"),
LocalRSSHelper.RSSType.UNKNOWN)
assertEquals(LocalRSSHelper.getRSSType("text/html"),
LocalRSSHelper.RSSType.UNKNOWN)
}
@Test
fun rss1ContentTest() {
assertEquals(LocalRSSHelper.getRSSContentType(ByteArrayInputStream(
"""<?xml-stylesheet type="text/xsl" media="screen" href="/~d/styles/rss1full.xsl"?>
<?xml-stylesheet type="text/css" media="screen" href="http://rss.slashdot.org/~d/styles/itemcontent.css"?>
<rdf:RDF xmlns:admin="http://webns.net/mvcb/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/"
""".trimIndent().toByteArray()
)), LocalRSSHelper.RSSType.RSS_1)
}
@Test
fun rss2ContentTest() {
assertEquals(LocalRSSHelper.getRSSContentType(ByteArrayInputStream(
"""<rss
xmlns:content="http://purl.org/rss/1.0/modules/content/"
xmlns:wfw="http://wellformedweb.org/CommentAPI/"
xmlns:dc="http://purl.org/dc/elements/1.1/"
version="2.0"
xmlns:atom="http://www.w3.org/2005/Atom"
xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
</rss>""".toByteArray()
)), LocalRSSHelper.RSSType.RSS_2)
}
@Test
fun atomContentTest() {
assertEquals(LocalRSSHelper.getRSSContentType(ByteArrayInputStream(
"""<feed xmlns="http://www.w3.org/2005/Atom">
</feed>""".toByteArray()
)), LocalRSSHelper.RSSType.ATOM)
}
@Test
fun unknownContentTest() {
assertEquals(LocalRSSHelper.getRSSContentType(ByteArrayInputStream(
"""<html>
<body>
</body>
</html>""".trimMargin().toByteArray()
)), LocalRSSHelper.RSSType.UNKNOWN)
}
}

View File

@ -0,0 +1,32 @@
package com.readrops.api.localfeed
import com.readrops.api.localfeed.atom.ATOMFeedAdapter
import com.readrops.api.localfeed.atom.ATOMItemsAdapter
import com.readrops.api.localfeed.rss1.RSS1FeedAdapter
import com.readrops.api.localfeed.rss1.RSS1ItemsAdapter
import com.readrops.api.localfeed.rss2.RSS2FeedAdapter
import com.readrops.api.localfeed.rss2.RSS2ItemsAdapter
import junit.framework.TestCase.assertTrue
import org.junit.Assert
import org.junit.Test
class XmlAdapterTest {
@Test
fun xmlFeedAdapterFactoryTest() {
assertTrue(XmlAdapter.xmlFeedAdapterFactory(LocalRSSHelper.RSSType.RSS_1) is RSS1FeedAdapter)
assertTrue(XmlAdapter.xmlFeedAdapterFactory(LocalRSSHelper.RSSType.RSS_2) is RSS2FeedAdapter)
assertTrue(XmlAdapter.xmlFeedAdapterFactory(LocalRSSHelper.RSSType.ATOM) is ATOMFeedAdapter)
Assert.assertThrows(IllegalArgumentException::class.java) { XmlAdapter.xmlFeedAdapterFactory(LocalRSSHelper.RSSType.UNKNOWN) }
}
@Test
fun xmlItemsAdapterFactoryTest() {
assertTrue(XmlAdapter.xmlItemsAdapterFactory(LocalRSSHelper.RSSType.RSS_1) is RSS1ItemsAdapter)
assertTrue(XmlAdapter.xmlItemsAdapterFactory(LocalRSSHelper.RSSType.RSS_2) is RSS2ItemsAdapter)
assertTrue(XmlAdapter.xmlItemsAdapterFactory(LocalRSSHelper.RSSType.ATOM) is ATOMItemsAdapter)
Assert.assertThrows(IllegalArgumentException::class.java) { XmlAdapter.xmlItemsAdapterFactory(LocalRSSHelper.RSSType.UNKNOWN) }
}
}

View File

@ -1,6 +1,4 @@
package com.readrops.app;
import com.readrops.app.utils.DateUtils;
package com.readrops.api.utils;
import org.joda.time.LocalDateTime;
import org.junit.Test;
@ -13,48 +11,48 @@ public class DateUtilsTest {
public void rssDateTest() {
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
assertEquals(0, dateTime.compareTo(DateUtils.stringToLocalDateTime("Fri, 04 Jan 2019 22:21:46 GMT")));
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46 GMT")));
}
@Test
public void rssDate2Test() {
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
assertEquals(0, dateTime.compareTo(DateUtils.stringToLocalDateTime("Fri, 04 Jan 2019 22:21:46 +0000")));
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46 +0000")));
}
@Test
public void rssDate3Test() {
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
assertEquals(0, dateTime.compareTo(DateUtils.stringToLocalDateTime("Fri, 04 Jan 2019 22:21:46")));
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46")));
}
@Test
public void atomJsonDateTest() {
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
assertEquals(0, dateTime.compareTo(DateUtils.stringToLocalDateTime("2019-01-04T22:21:46+00:00")));
assertEquals(0, dateTime.compareTo(DateUtils.parse("2019-01-04T22:21:46+00:00")));
}
@Test
public void atomJsonDate2Test() {
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
assertEquals(0, dateTime.compareTo(DateUtils.stringToLocalDateTime("2019-01-04T22:21:46-0000")));
assertEquals(0, dateTime.compareTo(DateUtils.parse("2019-01-04T22:21:46-0000")));
}
@Test
public void isoPatternTest() {
LocalDateTime dateTime = new LocalDateTime(2020, 6, 30, 11, 39, 37, 206);
assertEquals(0, dateTime.compareTo(DateUtils.stringToLocalDateTime("2020-06-30T11:39:37.206-07:00")));
assertEquals(0, dateTime.compareTo(DateUtils.parse("2020-06-30T11:39:37.206-07:00")));
}
@Test
public void edtPatternTest() {
LocalDateTime dateTime = new LocalDateTime(2020, 7, 17, 16, 30, 0);
assertEquals(0, dateTime.compareTo(DateUtils.stringToLocalDateTime("Fri, 17 Jul 2020 16:30:00 EDT")));
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 17 Jul 2020 16:30:00 EDT")));
}
}

View File

@ -0,0 +1,85 @@
package com.readrops.api.utils
import com.squareup.moshi.JsonReader
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNull
import okio.Buffer
import org.junit.Test
class JsonReaderExtensionsTest {
@Test
fun nextNullableStringNullCaseTest() {
val reader = JsonReader.of(Buffer().readFrom("""
{
"field": null
}
""".trimIndent().byteInputStream()))
reader.beginObject()
reader.nextName()
assertNull(reader.nextNullableString())
reader.endObject()
}
@Test
fun nextNullableStringEmptyCaseTest() {
val reader = JsonReader.of(Buffer().readFrom("""
{
"field": ""
}
""".trimIndent().byteInputStream()))
reader.beginObject()
reader.nextName()
assertNull(reader.nextNullableString())
reader.endObject()
}
@Test
fun nextNullableValueNormalCaseTest() {
val reader = JsonReader.of(Buffer().readFrom("""
{
"field": "value"
}
""".trimIndent().byteInputStream()))
reader.beginObject()
reader.nextName()
assertEquals(reader.nextNullableString(), "value")
reader.endObject()
}
@Test
fun nextNonEmptyStringTest() {
val reader = JsonReader.of(Buffer().readFrom("""
{
"field": "value"
}
""".trimIndent().byteInputStream()))
reader.beginObject()
reader.nextName()
assertEquals(reader.nextNullableString(), "value")
reader.endObject()
}
@Test(expected = ParseException::class)
fun nextNonEmptyStringEmptyCaseTest() {
val reader = JsonReader.of(Buffer().readFrom("""
{
"field": ""
}
""".trimIndent().byteInputStream()))
reader.beginObject()
reader.nextName()
reader.nextNonEmptyString()
}
}

View File

@ -0,0 +1,87 @@
package com.readrops.api.utils
import com.gitlab.mvysny.konsumexml.KonsumerException
import com.gitlab.mvysny.konsumexml.konsumeXml
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertNull
import org.junit.Test
class KonsumerExtensionsTest {
@Test(expected = KonsumerException::class)
fun nonNullTextNullCaseTest() {
val xml = """
<description></description>
""".trimIndent()
xml.konsumeXml().apply {
child("description") { nonNullText() }
}
}
@Test
fun nonNullTextNonNullCaseTest() {
val xml = """
<description>
description
</description>
""".trimIndent()
xml.konsumeXml().apply {
val description = child("description") { nonNullText() }
assertEquals(description, "description")
}
}
@Test
fun nullableTextNullCaseTest() {
val xml = """
<description></description>
""".trimIndent()
xml.konsumeXml().apply {
val description = child("description") { nullableText() }
assertNull(description)
}
}
@Test
fun nullableTextNonNullCaseTest() {
val xml = """
<description>
description
</description>
""".trimIndent()
xml.konsumeXml().apply {
val description = child("description") { nullableText() }
assertEquals(description, "description")
}
}
@Test
fun nullableTextRecursivelyNullCaseTest() {
val xml = """
<description></description>
""".trimIndent()
xml.konsumeXml().apply {
val description = child("description") { nullableTextRecursively() }
assertNull(description)
}
}
@Test
fun nullableTextRecursivelyNonNullCaseTest() {
val xml = """
<description>
descrip<a>tion</a>
</description>
""".trimIndent()
xml.konsumeXml().apply {
val description = child("description") { nullableTextRecursively() }
assertEquals(description, "description")
}
}
}

View File

@ -0,0 +1,25 @@
package com.readrops.api.utils
import junit.framework.TestCase.assertEquals
import org.junit.Test
class LibUtilsTest {
@Test
fun contentTypeWithCharsetTest() {
assertEquals(LibUtils.parseContentType("application/rss+xml; charset=UTF-8"),
"application/rss+xml")
}
@Test
fun contentTypeWithoutCharsetText() {
assertEquals(LibUtils.parseContentType("text/xml"),
"text/xml")
}
@Test
fun cleanTextTest() {
val text = " <p>This is a text<br/>to</p> clean "
assertEquals("This is a text to clean", LibUtils.cleanText(text))
}
}

View File

@ -33,7 +33,7 @@ import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition;
import com.readrops.app.R;
import com.readrops.app.databinding.ActivityItemBinding;
import com.readrops.app.utils.DateUtils;
import com.readrops.api.utils.DateUtils;
import com.readrops.app.utils.GlideApp;
import com.readrops.app.utils.PermissionManager;
import com.readrops.app.utils.SharedPreferencesManager;

View File

@ -29,7 +29,7 @@ import com.readrops.app.R;
import com.readrops.db.entities.Item;
import com.readrops.db.pojo.ItemWithFeed;
import com.readrops.app.databinding.ListItemBinding;
import com.readrops.app.utils.DateUtils;
import com.readrops.api.utils.DateUtils;
import com.readrops.app.utils.GlideRequests;
import com.readrops.app.utils.Utils;

View File

@ -2,30 +2,25 @@ package com.readrops.app.repositories;
import android.accounts.NetworkErrorException;
import android.content.Context;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.readrops.api.localfeed.LocalRSSDataSource;
import com.readrops.api.services.SyncResult;
import com.readrops.api.utils.HttpManager;
import com.readrops.api.utils.LibUtils;
import com.readrops.api.utils.ParseException;
import com.readrops.api.utils.UnknownFormatException;
import com.readrops.app.utils.FeedInsertionResult;
import com.readrops.app.utils.HtmlParser;
import com.readrops.app.utils.ParsingResult;
import com.readrops.app.utils.SharedPreferencesManager;
import com.readrops.app.utils.Utils;
import com.readrops.app.utils.matchers.FeedMatcher;
import com.readrops.app.utils.matchers.ItemMatcher;
import com.readrops.db.entities.Feed;
import com.readrops.db.entities.Item;
import com.readrops.db.entities.account.Account;
import com.readrops.api.localfeed.AFeed;
import com.readrops.api.localfeed.RSSQuery;
import com.readrops.api.localfeed.RSSQueryResult;
import com.readrops.api.localfeed.atom.ATOMFeed;
import com.readrops.api.localfeed.json.JSONFeed;
import com.readrops.api.localfeed.rss.RSSFeed;
import com.readrops.api.services.SyncResult;
import com.readrops.api.utils.LibUtils;
import com.readrops.api.utils.ParseException;
import com.readrops.api.utils.UnknownFormatException;
import org.jsoup.Jsoup;
@ -33,20 +28,24 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import io.reactivex.Observable;
import io.reactivex.Single;
import kotlin.Pair;
import okhttp3.Headers;
public class LocalFeedRepository extends ARepository<Void> {
private static final String TAG = LocalFeedRepository.class.getSimpleName();
private LocalRSSDataSource dataSource;
public LocalFeedRepository(@NonNull Context context, @Nullable Account account) {
super(context, account);
syncResult = new SyncResult();
dataSource = new LocalRSSDataSource(HttpManager.getInstance().getOkHttpClient());
}
@Override
@ -64,47 +63,31 @@ public class LocalFeedRepository extends ARepository<Void> {
return Observable.create(emitter -> {
List<Feed> feedList;
if (feeds == null || feeds.size() == 0)
if (feeds == null || feeds.isEmpty()) {
feedList = database.feedDao().getFeeds(account.getId());
else
feedList = new ArrayList<>(feeds);
RSSQuery rssQuery = new RSSQuery();
List<FeedInsertionResult> syncErrors = new ArrayList<>();
} else {
feedList = feeds;
}
for (Feed feed : feedList) {
emitter.onNext(feed);
FeedInsertionResult syncError = new FeedInsertionResult();
try {
HashMap<String, String> headers = new HashMap<>();
if (feed.getEtag() != null)
headers.put(LibUtils.IF_NONE_MATCH_HEADER, feed.getEtag());
if (feed.getLastModified() != null)
headers.put(LibUtils.IF_MODIFIED_HEADER, feed.getLastModified());
Headers.Builder headers = new Headers.Builder();
if (feed.getEtag() != null) {
headers.add(LibUtils.IF_NONE_MATCH_HEADER, feed.getEtag());
}
if (feed.getLastModified() != null) {
headers.add(LibUtils.IF_MODIFIED_HEADER, feed.getLastModified());
}
RSSQueryResult queryResult = rssQuery.queryUrl(feed.getUrl(), headers);
if (queryResult != null && queryResult.getException() == null)
insertNewItems(queryResult.getFeed(), queryResult.getRssType());
else if (queryResult != null && queryResult.getException() != null) {
Exception e = queryResult.getException();
Pair<Feed, List<Item>> pair = dataSource.queryRSSResource(feed.getUrl(), headers.build());
if (e instanceof UnknownFormatException)
syncError.setInsertionError(FeedInsertionResult.FeedInsertionError.FORMAT_ERROR);
else if (e instanceof NetworkErrorException)
syncError.setInsertionError(FeedInsertionResult.FeedInsertionError.NETWORK_ERROR);
syncError.setFeed(feed);
syncErrors.add(syncError);
if (pair != null) {
insertNewItems(feed, pair.getSecond());
}
} catch (Exception e) {
if (e instanceof IOException)
syncError.setInsertionError(FeedInsertionResult.FeedInsertionError.NETWORK_ERROR);
else
syncError.setInsertionError(FeedInsertionResult.FeedInsertionError.PARSE_ERROR);
syncError.setFeed(feed);
syncErrors.add(syncError);
Log.d(TAG, "sync: " + e.getMessage());
}
}
@ -121,28 +104,26 @@ public class LocalFeedRepository extends ARepository<Void> {
FeedInsertionResult insertionResult = new FeedInsertionResult();
try {
RSSQuery rssNet = new RSSQuery();
RSSQueryResult queryResult = rssNet.queryUrl(parsingResult.getUrl(), new HashMap<>());
Pair<Feed, List<Item>> pair = dataSource.queryRSSResource(parsingResult.getUrl(),
null);
Feed feed = insertFeed(pair.getFirst(), parsingResult);
if (queryResult != null && queryResult.getException() == null) {
Feed feed = insertFeed(queryResult.getFeed(), queryResult.getRssType(), parsingResult);
if (feed != null) {
insertionResult.setFeed(feed);
insertionResult.setParsingResult(parsingResult);
insertionResults.add(insertionResult);
}
} else if (queryResult != null && queryResult.getException() != null) {
insertionResult.setParsingResult(parsingResult);
insertionResult.setInsertionError(getErrorFromException(queryResult.getException()));
insertionResults.add(insertionResult);
if (feed != null) {
insertionResult.setFeed(feed);
}
} catch (ParseException e) {
Log.d(TAG, "addFeeds: " + e.getMessage());
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.PARSE_ERROR);
} catch (UnknownFormatException e) {
Log.d(TAG, "addFeeds: " + e.getMessage());
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.FORMAT_ERROR);
} catch (NetworkErrorException | IOException e) {
Log.d(TAG, "addFeeds: " + e.getMessage());
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.NETWORK_ERROR);
} catch (Exception e) {
if (e instanceof IOException)
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.NETWORK_ERROR);
else
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.PARSE_ERROR);
Log.d(TAG, "addFeeds: " + e.getMessage());
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.UNKNOWN_ERROR);
} finally {
insertionResult.setParsingResult(parsingResult);
insertionResults.add(insertionResult);
}
@ -152,67 +133,38 @@ public class LocalFeedRepository extends ARepository<Void> {
});
}
private void insertNewItems(AFeed feed, RSSQuery.RSSType type) throws ParseException {
Feed dbFeed;
List<Item> items;
@SuppressWarnings("SimplifyStreamApiCallChains")
private void insertNewItems(Feed feed, List<Item> items) {
database.feedDao().updateHeaders(feed.getEtag(), feed.getLastModified(), feed.getId());
switch (type) {
case RSS_2:
dbFeed = database.feedDao().getFeedByUrl(((RSSFeed) feed).getChannel().getFeedUrl(), account.getId());
items = ItemMatcher.itemsFromRSS(((RSSFeed) feed).getChannel().getItems(), dbFeed);
break;
case RSS_ATOM:
dbFeed = database.feedDao().getFeedByUrl(((ATOMFeed) feed).getUrl(), account.getId());
items = ItemMatcher.itemsFromATOM(((ATOMFeed) feed).getEntries(), dbFeed);
break;
case RSS_JSON:
dbFeed = database.feedDao().getFeedByUrl(((JSONFeed) feed).getFeedUrl(), account.getId());
items = ItemMatcher.itemsFromJSON(((JSONFeed) feed).getItems(), dbFeed);
break;
default:
throw new IllegalArgumentException("Unknown RSS type");
}
database.feedDao().updateHeaders(dbFeed.getEtag(), dbFeed.getLastModified(), dbFeed.getId());
Collections.sort(items, Item::compareTo);
int maxItems = Integer.parseInt(SharedPreferencesManager.readString(context, SharedPreferencesManager.SharedPrefKey.ITEMS_TO_PARSE_MAX_NB));
if (maxItems > 0 && items.size() > maxItems)
int maxItems = Integer.parseInt(SharedPreferencesManager.readString(context,
SharedPreferencesManager.SharedPrefKey.ITEMS_TO_PARSE_MAX_NB));
if (maxItems > 0 && items.size() > maxItems) {
items = items.subList(items.size() - maxItems, items.size());
insertItems(items, dbFeed);
}
private Feed insertFeed(AFeed feed, RSSQuery.RSSType type, ParsingResult parsingResult) {
Feed dbFeed;
switch (type) {
case RSS_2:
dbFeed = FeedMatcher.feedFromRSS((RSSFeed) feed);
break;
case RSS_ATOM:
dbFeed = FeedMatcher.feedFromATOM((ATOMFeed) feed);
break;
case RSS_JSON:
dbFeed = FeedMatcher.feedFromJSON((JSONFeed) feed);
break;
default:
throw new IllegalArgumentException("Unknown RSS type");
}
dbFeed.setFolderId(parsingResult.getFolderId());
items.stream().forEach(item -> item.setFeedId(feed.getId()));
insertItems(items, feed);
}
if (database.feedDao().feedExists(dbFeed.getUrl(), account.getId()))
private Feed insertFeed(Feed feed, ParsingResult parsingResult) {
feed.setFolderId(parsingResult.getFolderId());
if (database.feedDao().feedExists(feed.getUrl(), account.getId())) {
return null; // feed already inserted
}
setFeedColors(dbFeed);
dbFeed.setAccountId(account.getId());
setFeedColors(feed);
feed.setAccountId(account.getId());
// we need empty headers to query the feed just after, without any 304 result
dbFeed.setEtag(null);
dbFeed.setLastModified(null);
feed.setEtag(null);
feed.setLastModified(null);
dbFeed.setId((int) (database.feedDao().compatInsert(dbFeed)));
return dbFeed;
feed.setId((int) (database.feedDao().compatInsert(feed)));
return feed;
}
private void insertItems(Collection<Item> items, Feed feed) {
@ -253,13 +205,4 @@ public class LocalFeedRepository extends ARepository<Void> {
syncResult.getItems().addAll(itemsToInsert);
database.itemDao().insert(itemsToInsert);
}
private FeedInsertionResult.FeedInsertionError getErrorFromException(Exception e) {
if (e instanceof UnknownFormatException)
return FeedInsertionResult.FeedInsertionError.FORMAT_ERROR;
else if (e instanceof NetworkErrorException)
return FeedInsertionResult.FeedInsertionError.NETWORK_ERROR;
else
return FeedInsertionResult.FeedInsertionError.UNKNOWN_ERROR;
}
}

View File

@ -1,60 +0,0 @@
package com.readrops.app.utils;
import org.joda.time.LocalDateTime;
import org.joda.time.format.DateTimeFormat;
import org.joda.time.format.DateTimeFormatter;
import org.joda.time.format.DateTimeFormatterBuilder;
import java.util.Locale;
public final class DateUtils {
/**
* Base of common RSS 2 date formats.
* Examples :
* Fri, 04 Jan 2019 22:21:46 GMT
* Fri, 04 Jan 2019 22:21:46 +0000
*/
private static final String RSS_2_BASE_PATTERN = "EEE, dd MMM yyyy HH:mm:ss";
private static final String GMT_PATTERN = "ZZZ";
private static final String OFFSET_PATTERN = "Z";
private static final String ISO_PATTERN = ".SSSZZ";
private static final String EDT_PATTERN = "zzz";
/**
* Date pattern for format : 2019-01-04T22:21:46+00:00
*/
private static final String ATOM_JSON_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss";
public static LocalDateTime stringToLocalDateTime(String value) {
DateTimeFormatter formatter = new DateTimeFormatterBuilder()
.appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN + " ").getParser()) // with timezone
.appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).getParser()) // no timezone, important order here
.appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).getParser())
.appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).getParser())
.appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).getParser())
.appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).getParser())
.appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).getParser())
.toFormatter()
.withLocale(Locale.ENGLISH)
.withOffsetParsed();
return formatter.parseLocalDateTime(value);
}
public static String formattedDateByLocal(LocalDateTime dateTime) {
return DateTimeFormat.mediumDate()
.withLocale(Locale.getDefault())
.print(dateTime);
}
public static String formattedDateTimeByLocal(LocalDateTime dateTime) {
return DateTimeFormat.forPattern("dd MMM yyyy · HH:mm")
.withLocale(Locale.getDefault())
.print(dateTime);
}
}

View File

@ -17,8 +17,6 @@ import androidx.annotation.NonNull;
import com.google.android.material.snackbar.Snackbar;
import com.readrops.api.utils.HttpManager;
import org.jsoup.Jsoup;
import java.io.InputStream;
import java.util.Locale;
@ -97,16 +95,6 @@ public final class Utils {
snackbar.show();
}
/**
* Remove html tags and trim the text
*
* @param text string to clean
* @return cleaned text
*/
public static String cleanText(String text) {
return Jsoup.parse(text).text().trim();
}
public static Bitmap getBitmapFromDrawable(Drawable drawable) {
Bitmap bitmap = Bitmap.createBitmap(drawable.getIntrinsicWidth(),
drawable.getIntrinsicHeight(), Bitmap.Config.ARGB_8888);

View File

@ -1,65 +0,0 @@
package com.readrops.app.utils.matchers;
import com.readrops.db.entities.Feed;
import com.readrops.api.localfeed.atom.ATOMFeed;
import com.readrops.api.localfeed.json.JSONFeed;
import com.readrops.api.localfeed.rss.RSSChannel;
import com.readrops.api.localfeed.rss.RSSFeed;
import org.jsoup.Jsoup;
public final class FeedMatcher {
public static Feed feedFromRSS(RSSFeed rssFeed) {
Feed feed = new Feed();
RSSChannel channel = rssFeed.getChannel();
feed.setName(Jsoup.parse(channel.getTitle()).text());
feed.setUrl(channel.getFeedUrl());
feed.setSiteUrl(channel.getUrl());
feed.setDescription(channel.getDescription());
feed.setLastUpdated(channel.getLastUpdated());
feed.setEtag(rssFeed.getEtag());
feed.setLastModified(rssFeed.getLastModified());
feed.setFolderId(null);
return feed;
}
public static Feed feedFromATOM(ATOMFeed atomFeed) {
Feed feed = new Feed();
feed.setName(atomFeed.getTitle());
feed.setDescription(atomFeed.getSubtitle());
feed.setUrl(atomFeed.getUrl());
feed.setSiteUrl(atomFeed.getWebsiteUrl());
feed.setDescription(atomFeed.getSubtitle());
feed.setLastUpdated(atomFeed.getUpdated());
feed.setEtag(atomFeed.getEtag());
feed.setLastModified(atomFeed.getLastModified());
feed.setFolderId(null);
return feed;
}
public static Feed feedFromJSON(JSONFeed jsonFeed) {
Feed feed = new Feed();
feed.setName(jsonFeed.getTitle());
feed.setUrl(jsonFeed.getFeedUrl());
feed.setSiteUrl(jsonFeed.getHomePageUrl());
feed.setDescription(jsonFeed.getDescription());
feed.setEtag(jsonFeed.getEtag());
feed.setLastModified(jsonFeed.getLastModified());
feed.setIconUrl(jsonFeed.getFaviconUrl());
feed.setFolderId(null);
return feed;
}
}

View File

@ -1,122 +0,0 @@
package com.readrops.app.utils.matchers;
import com.readrops.app.utils.DateUtils;
import com.readrops.app.utils.Utils;
import com.readrops.db.entities.Feed;
import com.readrops.db.entities.Item;
import com.readrops.api.localfeed.atom.ATOMEntry;
import com.readrops.api.localfeed.json.JSONItem;
import com.readrops.api.localfeed.rss.RSSEnclosure;
import com.readrops.api.localfeed.rss.RSSItem;
import com.readrops.api.localfeed.rss.RSSMediaContent;
import com.readrops.api.utils.ParseException;
import java.util.ArrayList;
import java.util.List;
public final class ItemMatcher {
public static List<Item> itemsFromRSS(List<RSSItem> items, Feed feed) throws ParseException {
List<Item> dbItems = new ArrayList<>();
for (RSSItem item : items) {
Item newItem = new Item();
newItem.setAuthor(item.getAuthor());
newItem.setContent(item.getContent()); // Jsoup.clean(item.getContent(), Whitelist.relaxed())
newItem.setDescription(item.getDescription());
newItem.setGuid(item.getGuid() != null ? item.getGuid() : item.getLink());
newItem.setTitle(Utils.cleanText(item.getTitle()));
try {
newItem.setPubDate(DateUtils.stringToLocalDateTime(item.getDate()));
} catch (Exception e) {
throw new ParseException();
}
newItem.setLink(item.getLink());
newItem.setFeedId(feed.getId());
if (item.getMediaContents() != null && !item.getMediaContents().isEmpty()) {
for (RSSMediaContent mediaContent : item.getMediaContents()) {
if (mediaContent.getMedium() != null && Utils.isTypeImage(mediaContent.getMedium())) {
newItem.setImageLink(mediaContent.getUrl());
break;
}
}
} else {
if (item.getEnclosures() != null) {
for (RSSEnclosure enclosure : item.getEnclosures()) {
if (enclosure.getType() != null && Utils.isTypeImage(enclosure.getType())
&& enclosure.getUrl() != null) {
newItem.setImageLink(enclosure.getUrl());
break;
}
}
}
}
dbItems.add(newItem);
}
return dbItems;
}
public static List<Item> itemsFromATOM(List<ATOMEntry> items, Feed feed) throws ParseException {
List<Item> dbItems = new ArrayList<>();
for (ATOMEntry item : items) {
Item dbItem = new Item();
dbItem.setContent(item.getContent()); // Jsoup.clean(item.getContent(), Whitelist.relaxed())
dbItem.setDescription(item.getSummary());
dbItem.setGuid(item.getId());
dbItem.setTitle(Utils.cleanText(item.getTitle()));
try {
dbItem.setPubDate(DateUtils.stringToLocalDateTime(item.getUpdated()));
} catch (Exception e) {
throw new ParseException();
}
dbItem.setLink(item.getUrl());
dbItem.setFeedId(feed.getId());
dbItems.add(dbItem);
}
return dbItems;
}
public static List<Item> itemsFromJSON(List<JSONItem> items, Feed feed) throws ParseException {
List<Item> dbItems = new ArrayList<>();
for (JSONItem item : items) {
Item dbItem = new Item();
if (item.getAuthor() != null)
dbItem.setAuthor(item.getAuthor().getName());
dbItem.setContent(item.getContent()); // Jsoup.clean(item.getContent(), Whitelist.relaxed())
dbItem.setDescription(item.getSummary());
dbItem.setGuid(item.getId());
dbItem.setTitle(Utils.cleanText(item.getTitle()));
try {
dbItem.setPubDate(DateUtils.stringToLocalDateTime(item.getPubDate()));
} catch (Exception e) {
throw new ParseException();
}
dbItem.setLink(item.getUrl());
dbItem.setFeedId(feed.getId());
dbItems.add(dbItem);
}
return dbItems;
}
}

View File

@ -7,13 +7,14 @@ import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LiveData;
import com.readrops.db.Database;
import com.readrops.db.entities.account.Account;
import com.readrops.api.localfeed.LocalRSSDataSource;
import com.readrops.api.utils.HttpManager;
import com.readrops.app.repositories.ARepository;
import com.readrops.app.utils.FeedInsertionResult;
import com.readrops.app.utils.HtmlParser;
import com.readrops.app.utils.ParsingResult;
import com.readrops.api.localfeed.RSSQuery;
import com.readrops.db.Database;
import com.readrops.db.entities.account.Account;
import java.util.ArrayList;
import java.util.List;
@ -47,13 +48,12 @@ public class AddFeedsViewModel extends AndroidViewModel {
public Single<List<ParsingResult>> parseUrl(String url) {
return Single.create(emitter -> {
RSSQuery rssApi = new RSSQuery();
LocalRSSDataSource dataSource = new LocalRSSDataSource(HttpManager.getInstance().getOkHttpClient());
List<ParsingResult> results = new ArrayList<>();
if (rssApi.isUrlFeedLink(url)) {
if (dataSource.isUrlRSSResource(url)) {
ParsingResult parsingResult = new ParsingResult(url, null);
results.add(parsingResult);
} else {
results.addAll(HtmlParser.getFeedLink(url));
}

View File

@ -7,17 +7,9 @@ import com.readrops.app.utils.Utils;
import org.junit.Test;
import static junit.framework.Assert.assertTrue;
import static junit.framework.TestCase.assertEquals;
public class UtilsTest {
@Test
public void cleanTextTest() {
String text = " <p>This is a text<br/>to</p> clean ";
assertEquals("This is a text to clean", Utils.cleanText(text));
}
@Test
public void colorTooBrightTest() {
assertTrue(Utils.isColorTooBright(-986896));

View File

@ -29,6 +29,9 @@ public abstract class FeedDao implements BaseDao<Feed> {
@Query("Select * from Feed Where id = :feedId")
public abstract Feed getFeedById(int feedId);
@Query("Select id From Feed Where url = :url and account_id = :accountId")
public abstract int getFeedIdByUrl(String url, int accountId);
@Query("Select case When :feedUrl In (Select url from Feed Where account_id = :accountId) Then 1 else 0 end")
public abstract boolean feedExists(String feedUrl, int accountId);