Add data source for local rss, with tests

This commit is contained in:
Shinokuni 2020-08-25 23:35:19 +02:00
parent 355b5a4375
commit 51cea7c4c2
10 changed files with 288 additions and 2 deletions

View File

@ -56,8 +56,13 @@ dependencies {
androidTestImplementation 'androidx.test:runner:1.2.0' androidTestImplementation 'androidx.test:runner:1.2.0'
androidTestImplementation 'androidx.test:rules:1.2.0' androidTestImplementation 'androidx.test:rules:1.2.0'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.8.1'
implementation 'com.squareup.retrofit2:retrofit:2.7.1' 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') { 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 exclude group: 'moshi', module: 'moshi' // moshi converter uses moshi 1.8.0 which breaks codegen 1.9.2
} }

View File

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<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>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273602">Comments</a>]]></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

@ -0,0 +1,116 @@
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.ParseException
import junit.framework.TestCase.*
import okhttp3.HttpUrl
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
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()
url = mockServer.url("/rss")
}
@After
fun tearDown() {
mockServer.close()
}
@Test
fun successfulQueryTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader("Content-Type", "application/rss+xml; charset=UTF-8")
.setBody(context.resources.assets.open("localfeed/rss_feed.xml").toString()))
val pair = localRSSDataSource.queryRSSResource(url.toString(), null, false)
assertNotNull(pair?.first)
assertNotNull(pair?.second)
}
@Test
fun response304Test() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_MODIFIED))
val pair = localRSSDataSource.queryRSSResource(url.toString(), null, false)
assertNull(pair)
}
@Test(expected = NetworkErrorException::class)
fun response404Test() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
localRSSDataSource.queryRSSResource(url.toString(), null, false)
}
@Test(expected = ParseException::class)
fun noContentTypeTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK))
localRSSDataSource.queryRSSResource(url.toString(), null, false)
}
@Test(expected = ParseException::class)
fun badContentTypeTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader("Content-Type", ""))
localRSSDataSource.queryRSSResource(url.toString(), null, false)
}
@Test(expected = ParseException::class)
fun badContentTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader("Content-Type", "application/xml")
.setBody("<html> </html>"))
localRSSDataSource.queryRSSResource(url.toString(), null, false)
}
@Test
fun isUrlResourceSuccessfulTest() {
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader("Content-Type", "application/atom+xml"))
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")
.setBody("<html> </html>"))
assertFalse(localRSSDataSource.isUrlRSSResource(url.toString()))
}
}

View File

@ -3,7 +3,10 @@
<!-- for tests only --> <!-- for tests only -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <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> </manifest>

View File

@ -0,0 +1,88 @@
package com.readrops.api.localfeed
import android.accounts.NetworkErrorException
import androidx.annotation.WorkerThread
import com.readrops.api.utils.LibUtils
import com.readrops.api.utils.ParseException
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Item
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
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
* @param withItems parse items with their feed
* @return a Feed object with its items if specified by [withItems]
*/
@WorkerThread
fun queryRSSResource(url: String, headers: Headers?, withItems: Boolean): Pair<Feed, List<Item>>? {
val response = queryUrl(url, headers)
return when {
response.isSuccessful -> {
val header = response.header(LibUtils.CONTENT_TYPE_HEADER)
?: throw ParseException("Unable to get $url content-type")
val contentType = LibUtils.parseContentType(header)
?: throw ParseException("Unable to get $url content-type")
var type = LocalRSSHelper.getRSSType(contentType)
// if we can't guess type based on content-type header, we use the content
if (type == LocalRSSHelper.RSSType.UNKNOWN) type = LocalRSSHelper.getRSSContentType(response.body?.byteStream()!!)
// if we can't guess type even with the content, we are unable to go further
if (type == LocalRSSHelper.RSSType.UNKNOWN) throw ParseException("Unable to guess $url RSS type")
val feed = parseFeed(response, type)
val items = if (withItems) parseItems(response.body?.byteStream()!!, type) else listOf()
return Pair(feed, items)
}
response.code == HttpURLConnection.HTTP_NOT_MODIFIED -> null
else -> throw NetworkErrorException("$url returned ${response.code} code : ${response.message}")
}
}
@WorkerThread
fun isUrlRSSResource(url: String): Boolean {
val response = queryUrl(url, null)
return if (response.isSuccessful) {
val contentType = response.header(LibUtils.CONTENT_TYPE_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(response: Response, type: LocalRSSHelper.RSSType): Feed {
response.body?.close()
return Feed()
}
private fun parseItems(inputStream: InputStream, type: LocalRSSHelper.RSSType): List<Item> {
return listOf()
}
}

View File

@ -4,10 +4,13 @@ import android.content.Context;
import android.net.Uri; import android.net.Uri;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.InputStream; import java.io.InputStream;
import java.util.Scanner; import java.util.Scanner;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public final class LibUtils { public final class LibUtils {
@ -28,6 +31,8 @@ public final class LibUtils {
public static final int HTTP_NOT_FOUND = 404; public static final int HTTP_NOT_FOUND = 404;
public static final int HTTP_CONFLICT = 409; public static final int HTTP_CONFLICT = 409;
private static final String RSS_CONTENT_TYPE_REGEX = "([^;]+)";
public static String inputStreamToString(InputStream input) { public static String inputStreamToString(InputStream input) {
Scanner scanner = new Scanner(input).useDelimiter("\\A"); Scanner scanner = new Scanner(input).useDelimiter("\\A");
@ -44,4 +49,16 @@ public final class LibUtils {
return type.equals("image") || type.equals("image/jpeg") || type.equals("image/jpg") return type.equals("image") || type.equals("image/jpeg") || type.equals("image/jpg")
|| type.equals("image/png"); || 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;
}
}
} }