Merge branch 'develop'
This commit is contained in:
commit
a07ac0adb8
8
.github/workflows/android.yml
vendored
8
.github/workflows/android.yml
vendored
@ -3,12 +3,10 @@ name: Android CI
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- '**'
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -24,7 +22,7 @@ jobs:
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew clean build
|
||||
- name: Android Emulator Runner
|
||||
uses: ReactiveCircus/android-emulator-runner@v2.5.0
|
||||
uses: ReactiveCircus/android-emulator-runner@v2.19.0
|
||||
with:
|
||||
api-level: 29
|
||||
script: ./gradlew connectedCheck
|
||||
|
@ -1,6 +1,5 @@
|
||||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
android {
|
||||
@ -18,6 +17,10 @@ android {
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/androidTest/assets".toString())
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
@ -33,37 +36,45 @@ android {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
testImplementation 'junit:junit:4.13'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
|
||||
androidTestImplementation "io.insert-koin:koin-test:2.2.3"
|
||||
testImplementation "io.insert-koin:koin-test:2.2.3"
|
||||
|
||||
implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:1.0'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
|
||||
|
||||
implementation('com.squareup.retrofit2:retrofit:2.9.0') {
|
||||
exclude group: 'okhttp3', module: 'okhttp3'
|
||||
}
|
||||
|
||||
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:runner:1.2.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
|
||||
|
||||
implementation 'com.squareup.retrofit2:retrofit:2.7.1'
|
||||
implementation('com.squareup.retrofit2:converter-moshi:2.7.1') {
|
||||
implementation('com.squareup.retrofit2:converter-moshi:2.9.0') {
|
||||
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:adapter-rxjava2:2.7.1'
|
||||
implementation ('com.squareup.retrofit2:converter-simplexml:2.9.0')
|
||||
|
||||
implementation 'com.squareup.moshi:moshi:1.9.2'
|
||||
kapt 'com.squareup.moshi:moshi-kotlin-codegen:1.9.2'
|
||||
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
|
||||
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:4.2.0'
|
||||
implementation 'com.squareup.moshi:moshi:1.12.0'
|
||||
kapt 'com.squareup.moshi:moshi-kotlin-codegen:1.12.0'
|
||||
|
||||
api 'io.reactivex.rxjava2:rxandroid:2.1.1'
|
||||
api 'org.jsoup:jsoup:1.12.1'
|
||||
api 'org.jsoup:jsoup:1.13.1'
|
||||
|
||||
debugApi 'com.chimerapps.niddler:niddler:1.5.5'
|
||||
releaseApi 'com.chimerapps.niddler:niddler-noop:1.5.5'
|
||||
}
|
||||
|
9
api/src/androidTest/assets/opml/lite_subscriptions.opml
Normal file
9
api/src/androidTest/assets/opml/lite_subscriptions.opml
Normal file
@ -0,0 +1,9 @@
|
||||
<opml version="2.0">
|
||||
<body>
|
||||
<outline>
|
||||
<outline title="The Verge" xmlUrl='http://www.theverge.com/rss/index.xml' htmlUrl="http://www.theverge.com" />
|
||||
</outline>
|
||||
|
||||
<outline title="TechCrunch" xmlUrl='https://techcrunch.com/feed/' htmlUrl="https://techcrunch.com/" />
|
||||
</body>
|
||||
</opml>
|
27
api/src/androidTest/assets/opml/subscriptions.opml
Executable file
27
api/src/androidTest/assets/opml/subscriptions.opml
Executable file
@ -0,0 +1,27 @@
|
||||
<opml version="2.0">
|
||||
<body>
|
||||
<outline text="Folder 1" title="Folder 1">
|
||||
<outline text="Subfolder 1" title="Subfolder 1">
|
||||
<outline title="The Verge" xmlUrl='http://www.theverge.com/rss/index.xml' htmlUrl="http://www.theverge.com" />
|
||||
<outline title="TechCrunch" xmlUrl='https://techcrunch.com/feed/' htmlUrl="https://techcrunch.com/" />
|
||||
<outline xmlUrl='http://feeds.mashable.com/Mashable' />
|
||||
<outline xmlUrl='http://www.engadget.com/rss.xml' />
|
||||
</outline>
|
||||
|
||||
<outline text="Subfolder 2" title="Subfolder 2">
|
||||
<outline text="Sub subfolder 1" title="Sub subfolder 1">
|
||||
<outline title="The Verge" xmlUrl='http://www.theverge.com/rss/index.xml' htmlUrl="http://www.theverge.com" />
|
||||
<outline title="TechCrunch" xmlUrl='https://techcrunch.com/feed/' htmlUrl="https://techcrunch.com/" />
|
||||
</outline>
|
||||
<outline text="Sub subfolder 2" title="Sub subfolder 2">
|
||||
</outline>
|
||||
<outline xmlUrl='http://www.engadget.com/rss.xml' />
|
||||
</outline>
|
||||
<outline xmlUrl='http://feeds.mashable.com/Mashable' />
|
||||
<outline xmlUrl='http://www.engadget.com/rss.xml' />
|
||||
</outline>
|
||||
|
||||
<outline title="The Verge" xmlUrl='http://www.theverge.com/rss/index.xml' htmlUrl="http://www.theverge.com" />
|
||||
<outline title="TechCrunch" xmlUrl='https://techcrunch.com/feed/' htmlUrl="https://techcrunch.com/" />
|
||||
</body>
|
||||
</opml>
|
6
api/src/androidTest/assets/opml/wrong_version.opml
Normal file
6
api/src/androidTest/assets/opml/wrong_version.opml
Normal file
@ -0,0 +1,6 @@
|
||||
<opml version="1.0">
|
||||
<body>
|
||||
<outline title="The Verge" xmlUrl='http://www.theverge.com/rss/index.xml' htmlUrl="http://www.theverge.com" />
|
||||
<outline title="TechCrunch" xmlUrl='https://techcrunch.com/feed/' htmlUrl="https://techcrunch.com/" />
|
||||
</body>
|
||||
</opml>
|
@ -1,26 +0,0 @@
|
||||
package com.readrops.api;
|
||||
|
||||
import android.content.Context;
|
||||
import androidx.test.InstrumentationRegistry;
|
||||
import androidx.test.runner.AndroidJUnit4;
|
||||
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Instrumented test, which will execute on an Android device.
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
@RunWith(AndroidJUnit4.class)
|
||||
public class ExampleInstrumentedTest {
|
||||
@Test
|
||||
public void useAppContext() {
|
||||
// Context of the app under test.
|
||||
Context appContext = InstrumentationRegistry.getTargetContext();
|
||||
|
||||
assertEquals("com.readrops.api.test", appContext.getPackageName());
|
||||
}
|
||||
}
|
112
api/src/androidTest/java/com/readrops/api/OPMLParserTest.kt
Normal file
112
api/src/androidTest/java/com/readrops/api/OPMLParserTest.kt
Normal file
@ -0,0 +1,112 @@
|
||||
package com.readrops.api
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.test.rule.GrantPermissionRule
|
||||
import com.readrops.api.opml.OPMLParser
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class OPMLParserTest {
|
||||
|
||||
private val context: Context = InstrumentationRegistry.getInstrumentation().context
|
||||
|
||||
@get:Rule
|
||||
val permissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
|
||||
@Test
|
||||
fun readOpmlTest() {
|
||||
val stream = context.resources.assets.open("opml/subscriptions.opml")
|
||||
|
||||
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
|
||||
|
||||
OPMLParser.read(stream)
|
||||
.observeOn(Schedulers.trampoline())
|
||||
.subscribeOn(Schedulers.trampoline())
|
||||
.subscribe { result -> foldersAndFeeds = result }
|
||||
|
||||
assertEquals(foldersAndFeeds?.size, 6)
|
||||
|
||||
assertEquals(foldersAndFeeds?.get(Folder("Folder 1"))?.size, 2)
|
||||
assertEquals(foldersAndFeeds?.get(Folder("Subfolder 1"))?.size, 4)
|
||||
assertEquals(foldersAndFeeds?.get(Folder("Subfolder 2"))?.size, 1)
|
||||
assertEquals(foldersAndFeeds?.get(Folder("Sub subfolder 1"))?.size, 2)
|
||||
assertEquals(foldersAndFeeds?.get(Folder("Sub subfolder 2"))?.size, 0)
|
||||
assertEquals(foldersAndFeeds?.get(null)?.size, 2)
|
||||
|
||||
stream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readLiteSubscriptionsTest() {
|
||||
val stream = context.resources.assets.open("opml/lite_subscriptions.opml")
|
||||
|
||||
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
|
||||
|
||||
OPMLParser.read(stream)
|
||||
.subscribe { result -> foldersAndFeeds = result }
|
||||
|
||||
assertEquals(foldersAndFeeds?.values?.first()?.size, 2)
|
||||
assertEquals(foldersAndFeeds?.values?.first()?.first()?.url, "http://www.theverge.com/rss/index.xml")
|
||||
assertEquals(foldersAndFeeds?.values?.first()?.get(1)?.url, "https://techcrunch.com/feed/")
|
||||
|
||||
stream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opmlVersionTest() {
|
||||
val stream = context.resources.assets.open("opml/wrong_version.opml")
|
||||
|
||||
OPMLParser.read(stream)
|
||||
.test()
|
||||
.assertError(ParseException::class.java)
|
||||
|
||||
stream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writeOpmlTest() {
|
||||
val filePath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).absolutePath
|
||||
val file = File(filePath, "subscriptions.opml")
|
||||
|
||||
val outputStream: OutputStream = FileOutputStream(file)
|
||||
val foldersAndFeeds: Map<Folder?, List<Feed>> = HashMap<Folder?, List<Feed>>().apply {
|
||||
put(null, listOf(Feed("Feed1", "", "https://feed1.com"),
|
||||
Feed("Feed2", "", "https://feed2.com")))
|
||||
put(Folder("Folder1"), listOf())
|
||||
put(Folder("Folder2"), listOf(Feed("Feed3", "", "https://feed3.com"),
|
||||
Feed("Feed4", "", "https://feed4.com")))
|
||||
}
|
||||
|
||||
OPMLParser.write(foldersAndFeeds, outputStream)
|
||||
.subscribeOn(Schedulers.trampoline())
|
||||
.subscribe()
|
||||
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
|
||||
val inputStream = file.inputStream()
|
||||
var foldersAndFeeds2: Map<Folder?, List<Feed>>? = null
|
||||
OPMLParser.read(inputStream).subscribe { result -> foldersAndFeeds2 = result }
|
||||
|
||||
assertEquals(foldersAndFeeds.size, foldersAndFeeds2?.size)
|
||||
assertEquals(foldersAndFeeds[Folder("Folder1")]?.size, foldersAndFeeds2?.get(Folder("Folder1"))?.size)
|
||||
assertEquals(foldersAndFeeds[Folder("Folder2")]?.size, foldersAndFeeds2?.get(Folder("Folder2"))?.size)
|
||||
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2?.get(null)?.size)
|
||||
|
||||
inputStream.close()
|
||||
}
|
||||
}
|
12
api/src/debug/AndroidManifest.xml
Normal file
12
api/src/debug/AndroidManifest.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.readrops.api">
|
||||
|
||||
<!-- for tests only -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:usesCleartextTraffic="true" />
|
||||
|
||||
</manifest>
|
100
api/src/main/java/com/readrops/api/ApiModule.kt
Normal file
100
api/src/main/java/com/readrops/api/ApiModule.kt
Normal file
@ -0,0 +1,100 @@
|
||||
package com.readrops.api
|
||||
|
||||
import com.chimerapps.niddler.interceptor.okhttp.NiddlerOkHttpInterceptor
|
||||
import com.readrops.api.localfeed.LocalRSSDataSource
|
||||
import com.readrops.api.services.Credentials
|
||||
import com.readrops.api.services.freshrss.FreshRSSDataSource
|
||||
import com.readrops.api.services.freshrss.FreshRSSService
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSFeedsAdapter
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSFoldersAdapter
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSItemsAdapter
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSItemsIdsAdapter
|
||||
import com.readrops.api.services.nextcloudnews.NextNewsDataSource
|
||||
import com.readrops.api.services.nextcloudnews.NextNewsService
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFeedsAdapter
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFoldersAdapter
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsItemsAdapter
|
||||
import com.readrops.api.utils.AuthInterceptor
|
||||
import com.readrops.db.entities.Item
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
val apiModule = module {
|
||||
|
||||
single(createdAtStart = true) {
|
||||
OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.addInterceptor(get<AuthInterceptor>())
|
||||
.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
|
||||
.build()
|
||||
}
|
||||
|
||||
single { AuthInterceptor() }
|
||||
|
||||
single { LocalRSSDataSource(get()) }
|
||||
|
||||
//region freshrss
|
||||
|
||||
factory { params -> FreshRSSDataSource(get(parameters = { params })) }
|
||||
|
||||
factory { params ->
|
||||
get<Retrofit>(named("freshrssRetrofit"), parameters = { params })
|
||||
.create(FreshRSSService::class.java)
|
||||
}
|
||||
|
||||
factory(named("freshrssRetrofit")) { (credentials: Credentials) ->
|
||||
Retrofit.Builder()
|
||||
.baseUrl(credentials.url)
|
||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
||||
.client(get())
|
||||
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
|
||||
.build()
|
||||
}
|
||||
|
||||
single(named("freshrssMoshi")) {
|
||||
Moshi.Builder()
|
||||
.add(Types.newParameterizedType(List::class.java, Item::class.java), FreshRSSItemsAdapter())
|
||||
.add(Types.newParameterizedType(List::class.java, String::class.java), FreshRSSItemsIdsAdapter())
|
||||
.add(FreshRSSFeedsAdapter())
|
||||
.add(FreshRSSFoldersAdapter())
|
||||
.build()
|
||||
}
|
||||
|
||||
//endregion freshrss
|
||||
|
||||
//region nextcloud news
|
||||
|
||||
factory { params -> NextNewsDataSource(get(parameters = { params })) }
|
||||
|
||||
factory { params ->
|
||||
get<Retrofit>(named("nextcloudNewsRetrofit"), parameters = { params })
|
||||
.create(NextNewsService::class.java)
|
||||
}
|
||||
|
||||
factory(named("nextcloudNewsRetrofit")) { (credentials: Credentials) ->
|
||||
Retrofit.Builder()
|
||||
.baseUrl(credentials.url)
|
||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
||||
.client(get())
|
||||
.addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi"))))
|
||||
.build()
|
||||
}
|
||||
|
||||
single(named("nextcloudNewsMoshi")) {
|
||||
Moshi.Builder()
|
||||
.add(NextNewsFeedsAdapter())
|
||||
.add(NextNewsFoldersAdapter())
|
||||
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter())
|
||||
.build()
|
||||
}
|
||||
|
||||
//endregion nextcloud news
|
||||
}
|
@ -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
|
||||
}
|
@ -0,0 +1,152 @@
|
||||
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.ApiUtils
|
||||
import com.readrops.api.utils.AuthInterceptor
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.exceptions.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 org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class LocalRSSDataSource(private val httpClient: OkHttpClient): KoinComponent {
|
||||
|
||||
/**
|
||||
* 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>>? {
|
||||
get<AuthInterceptor>().credentials = null
|
||||
val response = queryUrl(url, headers)
|
||||
|
||||
return when {
|
||||
response.isSuccessful -> {
|
||||
val header = response.header(ApiUtils.CONTENT_TYPE_HEADER)
|
||||
?: throw UnknownFormatException("Unable to get $url content-type")
|
||||
|
||||
val contentType = ApiUtils.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(ApiUtils.CONTENT_TYPE_HEADER)
|
||||
?: return false
|
||||
|
||||
val contentType = ApiUtils.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(ApiUtils.ETAG_HEADER)
|
||||
feed.lastModified = response.header(ApiUtils.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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,66 @@
|
||||
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
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun isRSSType(type: String?): Boolean {
|
||||
return if (type != null) getRSSType(type) != RSSType.UNKNOWN else false
|
||||
}
|
||||
|
||||
enum class RSSType {
|
||||
RSS_1,
|
||||
RSS_2,
|
||||
ATOM,
|
||||
JSONFEED,
|
||||
UNKNOWN
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
36
api/src/main/java/com/readrops/api/localfeed/XmlAdapter.kt
Normal file
36
api/src/main/java/com/readrops/api/localfeed/XmlAdapter.kt
Normal file
@ -0,0 +1,36 @@
|
||||
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> = 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>> =
|
||||
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
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}*/
|
||||
}
|
@ -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.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.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) = with(konsume) {
|
||||
val rel = attributes.getValueOpt("rel")
|
||||
|
||||
if (rel == "self")
|
||||
feed.url = attributes["href"]
|
||||
else if (rel == "alternate")
|
||||
feed.siteUrl = attributes["href"]
|
||||
}
|
||||
|
||||
companion object {
|
||||
val names = Names.of("title", "link", "subtitle")
|
||||
}
|
||||
}
|
@ -0,0 +1,75 @@
|
||||
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.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.nullableText
|
||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||
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")
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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?)
|
@ -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()
|
@ -0,0 +1,43 @@
|
||||
package com.readrops.api.localfeed.json
|
||||
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||
import com.readrops.api.utils.extensions.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 = 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")
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
@ -0,0 +1,114 @@
|
||||
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.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||
import com.readrops.api.utils.extensions.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> = 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>) = with(reader) {
|
||||
beginArray()
|
||||
|
||||
while (hasNext()) {
|
||||
beginObject()
|
||||
val item = Item()
|
||||
|
||||
var contentText: String? = null
|
||||
var contentHtml: String? = null
|
||||
|
||||
while (hasNext()) {
|
||||
with(item) {
|
||||
when (selectName(names)) {
|
||||
0 -> guid = nextNonEmptyString()
|
||||
1 -> link = nextNonEmptyString()
|
||||
2 -> title = nextNonEmptyString()
|
||||
3 -> contentHtml = nextNullableString()
|
||||
4 -> contentText = nextNullableString()
|
||||
5 -> description = nextNullableString()
|
||||
6 -> imageLink = nextNullableString()
|
||||
7 -> pubDate = DateUtils.parse(nextNullableString())
|
||||
8 -> author = parseAuthor(reader) // jsonfeed 1.0
|
||||
9 -> author = parseAuthors(reader) // jsonfeed 1.1
|
||||
else -> skipValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
validateItem(item)
|
||||
item.content = if (contentHtml != null) contentHtml else contentText
|
||||
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
|
||||
|
||||
endObject()
|
||||
items += item
|
||||
}
|
||||
|
||||
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 += parseAuthor(reader)
|
||||
}
|
||||
|
||||
reader.endArray()
|
||||
|
||||
return if (authors.filterNotNull().isNotEmpty())
|
||||
authors.filterNotNull().joinToString(limit = AUTHORS_MAX) else null
|
||||
}
|
||||
|
||||
private fun validateItem(item: Item): Boolean = when {
|
||||
item.title == null -> throw ParseException("Item title is required")
|
||||
item.link == null -> throw ParseException("Item link is required")
|
||||
else -> true
|
||||
}
|
||||
|
||||
companion object {
|
||||
val names: JsonReader.Options = JsonReader.Options.of("id", "url", "title",
|
||||
"content_html", "content_text", "summary", "image", "date_published", "author", "authors")
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.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")
|
||||
}
|
||||
}
|
@ -0,0 +1,72 @@
|
||||
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.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.nullableText
|
||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||
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")
|
||||
}
|
||||
}
|
@ -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.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.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")
|
||||
}
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
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.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.nullableText
|
||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||
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 = ApiUtils.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) = with(konsumer) {
|
||||
if (attributes.getValueOpt("type") != null
|
||||
&& ApiUtils.isMimeImage(attributes["type"]) && item.imageLink == null)
|
||||
item.imageLink = attributes.getValueOpt("url")
|
||||
}
|
||||
|
||||
private fun isMediumImage(konsumer: Konsumer) = with(konsumer) {
|
||||
attributes.getValueOpt("medium") != null && ApiUtils.isMimeImage(attributes["medium"])
|
||||
}
|
||||
|
||||
private fun isTypeImage(konsumer: Konsumer) = with(konsumer) {
|
||||
attributes.getValueOpt("type") != null && ApiUtils.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?>) = with(item) {
|
||||
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")
|
||||
}
|
||||
}
|
27
api/src/main/java/com/readrops/api/opml/OPMLHelper.kt
Normal file
27
api/src/main/java/com/readrops/api/opml/OPMLHelper.kt
Normal file
@ -0,0 +1,27 @@
|
||||
package com.readrops.api.opml
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.fragment.app.Fragment
|
||||
|
||||
object OPMLHelper {
|
||||
|
||||
const val OPEN_OPML_FILE_REQUEST = 1
|
||||
|
||||
@JvmStatic
|
||||
fun openFileIntent(activity: Activity) =
|
||||
activity.startActivityForResult(createIntent(), OPEN_OPML_FILE_REQUEST)
|
||||
|
||||
@JvmStatic
|
||||
fun openFileIntent(fragment: Fragment) =
|
||||
fragment.startActivityForResult(createIntent(), OPEN_OPML_FILE_REQUEST)
|
||||
|
||||
|
||||
private fun createIntent(): Intent {
|
||||
return Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
|
||||
addCategory(Intent.CATEGORY_OPENABLE)
|
||||
type = "*/*"
|
||||
putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("application/*", "text/*"))
|
||||
}
|
||||
}
|
||||
}
|
@ -2,35 +2,52 @@ package com.readrops.api.opml
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.readrops.api.opml.model.Body
|
||||
import com.readrops.api.opml.model.Head
|
||||
import com.readrops.api.opml.model.OPML
|
||||
import com.readrops.api.opml.model.Outline
|
||||
import com.readrops.api.utils.LibUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.Single
|
||||
import io.reactivex.SingleOnSubscribe
|
||||
import org.simpleframework.xml.Serializer
|
||||
import org.simpleframework.xml.core.Persister
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object OPMLParser {
|
||||
|
||||
val TAG = OPMLParser.javaClass.simpleName
|
||||
|
||||
@JvmStatic
|
||||
fun read(uri: Uri, context: Context): Single<Map<Folder, List<Feed>>> {
|
||||
fun read(uri: Uri, context: Context): Single<Map<Folder?, List<Feed>>> {
|
||||
return Single.create(SingleOnSubscribe<InputStream> {
|
||||
val stream = context.contentResolver.openInputStream(uri)
|
||||
it.onSuccess(stream!!)
|
||||
}).flatMap { stream -> read(stream) }
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun read(stream: InputStream): Single<Map<Folder?, List<Feed>>> {
|
||||
return Single.create { emitter ->
|
||||
val fileString = LibUtils.fileToString(uri, context)
|
||||
val serializer: Serializer = Persister()
|
||||
try {
|
||||
val serializer: Serializer = Persister()
|
||||
|
||||
val opml: OPML = serializer.read(OPML::class.java, fileString)
|
||||
val opml: OPML = serializer.read(OPML::class.java, stream)
|
||||
|
||||
emitter.onSuccess(opmltoFoldersAndFeeds(opml))
|
||||
emitter.onSuccess(opmlToFoldersAndFeeds(opml))
|
||||
} catch (e: Exception) {
|
||||
Log.d(TAG, e.message, e)
|
||||
emitter.onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun write(foldersAndFeeds: Map<Folder, List<Feed>>, outputStream: OutputStream): Completable {
|
||||
fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream): Completable {
|
||||
return Completable.create { emitter ->
|
||||
val serializer: Serializer = Persister()
|
||||
serializer.write(foldersAndFeedsToOPML(foldersAndFeeds), outputStream)
|
||||
@ -39,44 +56,83 @@ object OPMLParser {
|
||||
}
|
||||
}
|
||||
|
||||
private fun opmltoFoldersAndFeeds(opml: OPML): Map<Folder, List<Feed>> {
|
||||
val foldersAndFeeds: MutableMap<Folder, List<Feed>> = HashMap()
|
||||
private fun opmlToFoldersAndFeeds(opml: OPML): Map<Folder?, List<Feed>> {
|
||||
if (opml.version != "2.0")
|
||||
throw ParseException("Only 2.0 OPML specification is supported")
|
||||
|
||||
val foldersAndFeeds: MutableMap<Folder?, List<Feed>> = HashMap()
|
||||
val body = opml.body!!
|
||||
|
||||
body.outlines?.forEach { outline ->
|
||||
val folder = Folder(outline.title)
|
||||
val outlineParsing = parseOutline(outline)
|
||||
associateOrphanFeedsToFolder(foldersAndFeeds, outlineParsing, null)
|
||||
|
||||
val feeds = arrayListOf<Feed>()
|
||||
outline.outlines?.forEach { feedOutline ->
|
||||
val feed = Feed().apply {
|
||||
name = feedOutline.title
|
||||
url = feedOutline.xmlUrl
|
||||
siteUrl = feedOutline.htmlUrl
|
||||
}
|
||||
|
||||
feeds.add(feed)
|
||||
}
|
||||
|
||||
foldersAndFeeds[folder] = feeds
|
||||
foldersAndFeeds.putAll(outlineParsing)
|
||||
}
|
||||
|
||||
return foldersAndFeeds
|
||||
}
|
||||
|
||||
private fun foldersAndFeedsToOPML(foldersAndFeeds: Map<Folder, List<Feed>>): OPML {
|
||||
val outlines = arrayListOf<Outline>()
|
||||
for (folderAndFeeds in foldersAndFeeds) {
|
||||
val outline = Outline(folderAndFeeds.key.name)
|
||||
/**
|
||||
* Parse outline and its children recursively
|
||||
* @param outline node to parse
|
||||
*/
|
||||
private fun parseOutline(outline: Outline): MutableMap<Folder?, List<Feed>> {
|
||||
val foldersAndFeeds: MutableMap<Folder?, List<Feed>> = HashMap()
|
||||
|
||||
val feedOutlines = arrayListOf<Outline>()
|
||||
folderAndFeeds.value.forEach { feed ->
|
||||
val feedOutline = Outline(feed.name, feed.url, feed.siteUrl)
|
||||
// The outline is a folder/category
|
||||
if ((outline.outlines != null && !outline.outlines?.isEmpty()!!) || outline.xmlUrl.isNullOrEmpty()) {
|
||||
// if the outline doesn't have text or title value but contains sub outlines,
|
||||
// those sub outlines will be considered as not belonging to any folder and join the others at the top level of the hierarchy
|
||||
val folder = if (outline.name != null) Folder(outline.name) else null
|
||||
|
||||
feedOutlines.add(feedOutline)
|
||||
outline.outlines?.forEach {
|
||||
val recursiveFeedsFolders = parseOutline(it)
|
||||
|
||||
// Treat feeds without folder, so belonging to the current folder
|
||||
associateOrphanFeedsToFolder(foldersAndFeeds, recursiveFeedsFolders, folder)
|
||||
foldersAndFeeds.putAll(recursiveFeedsFolders.toMap())
|
||||
}
|
||||
|
||||
outline.outlines = feedOutlines
|
||||
outlines.add(outline)
|
||||
// empty outline
|
||||
if (!foldersAndFeeds.containsKey(folder)) foldersAndFeeds[folder] = listOf()
|
||||
|
||||
} else { // the outline is a feed
|
||||
if (!outline.xmlUrl.isNullOrEmpty()) {
|
||||
val feed = Feed().apply {
|
||||
name = outline.name
|
||||
url = outline.xmlUrl
|
||||
siteUrl = outline.htmlUrl
|
||||
}
|
||||
// parsed feed is linked to null to be assigned to the previous level folder
|
||||
foldersAndFeeds[null] = listOf(feed)
|
||||
}
|
||||
}
|
||||
|
||||
return foldersAndFeeds
|
||||
}
|
||||
|
||||
private fun foldersAndFeedsToOPML(foldersAndFeeds: Map<Folder?, List<Feed>>): OPML {
|
||||
val outlines = arrayListOf<Outline>()
|
||||
|
||||
for (folderAndFeeds in foldersAndFeeds) {
|
||||
if (folderAndFeeds.key != null) {
|
||||
val outline = Outline(folderAndFeeds.key?.name)
|
||||
|
||||
val feedOutlines = arrayListOf<Outline>()
|
||||
for (feed in folderAndFeeds.value) {
|
||||
val feedOutline = Outline(feed.name, feed.url, feed.siteUrl)
|
||||
|
||||
feedOutlines += feedOutline
|
||||
}
|
||||
|
||||
outline.outlines = feedOutlines
|
||||
outlines += outline
|
||||
} else {
|
||||
for (feed in folderAndFeeds.value) {
|
||||
outlines += Outline(feed.name, feed.url, feed.siteUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val head = Head("Subscriptions")
|
||||
@ -85,4 +141,21 @@ object OPMLParser {
|
||||
return OPML("2.0", head, body)
|
||||
}
|
||||
|
||||
/**
|
||||
* Associate parsed feeds without folder to the previous level folder.
|
||||
* @param foldersAndFeeds final result
|
||||
* @param parsingResult current level parsing
|
||||
* @param folder the folder feeds will be associated to
|
||||
*
|
||||
*/
|
||||
private fun associateOrphanFeedsToFolder(foldersAndFeeds: MutableMap<Folder?, List<Feed>>,
|
||||
parsingResult: MutableMap<Folder?, List<Feed>>, folder: Folder?) {
|
||||
val feeds = parsingResult[null]
|
||||
if (feeds != null && feeds.isNotEmpty()) {
|
||||
if (foldersAndFeeds[folder] == null) foldersAndFeeds[folder] = feeds
|
||||
else foldersAndFeeds[folder] = foldersAndFeeds[folder]?.plus(feeds)!!
|
||||
|
||||
parsingResult.remove(null)
|
||||
}
|
||||
}
|
||||
}
|
@ -8,7 +8,7 @@ import org.simpleframework.xml.Root
|
||||
@Order(elements = ["head", "body"])
|
||||
@Root(name = "opml", strict = false)
|
||||
data class OPML(@field:Attribute(required = true) var version: String?,
|
||||
@field:Element(required = true) var head: Head?,
|
||||
@field:Element(required = false) var head: Head?,
|
||||
@field:Element(required = true) var body: Body?) {
|
||||
|
||||
/**
|
||||
|
@ -5,8 +5,8 @@ import org.simpleframework.xml.ElementList
|
||||
import org.simpleframework.xml.Root
|
||||
|
||||
@Root(name = "outline", strict = false)
|
||||
data class Outline(@field:Attribute(required = false) var title: String?,
|
||||
@field:Attribute(required = false) var text: String?,
|
||||
data class Outline(@field:Attribute(required = false) private var title: String?,
|
||||
@field:Attribute(required = false) private var text: String?,
|
||||
@field:Attribute(required = false) var type: String?,
|
||||
@field:Attribute(required = false) var xmlUrl: String?,
|
||||
@field:Attribute(required = false) var htmlUrl: String?,
|
||||
@ -23,7 +23,10 @@ data class Outline(@field:Attribute(required = false) var title: String?,
|
||||
null,
|
||||
null)
|
||||
|
||||
constructor(title: String) : this(title, null, null, null, null, null)
|
||||
constructor(title: String?) : this(title, title, null, null, null, null)
|
||||
|
||||
constructor(title: String, xmlUrl: String, htmlUrl: String) : this(title, title, "rss", xmlUrl, htmlUrl, null)
|
||||
constructor(title: String?, xmlUrl: String, htmlUrl: String?) : this(title, title, "rss", xmlUrl, htmlUrl, null)
|
||||
|
||||
val name: String?
|
||||
get() = title ?: text
|
||||
}
|
@ -1,61 +0,0 @@
|
||||
package com.readrops.api.services;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.readrops.api.utils.HttpManager;
|
||||
import com.squareup.moshi.Moshi;
|
||||
|
||||
import retrofit2.Retrofit;
|
||||
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory;
|
||||
|
||||
/**
|
||||
* Abstraction level for services APIs
|
||||
*
|
||||
* @param <T> an API service interface
|
||||
*/
|
||||
public abstract class API<T> {
|
||||
|
||||
protected static final int MAX_ITEMS = 5000;
|
||||
|
||||
protected T api;
|
||||
private Retrofit retrofit;
|
||||
|
||||
private Class<T> clazz;
|
||||
private String endPoint;
|
||||
|
||||
public API(Credentials credentials, @NonNull Class<T> clazz, @NonNull String endPoint) {
|
||||
this.clazz = clazz;
|
||||
this.endPoint = endPoint;
|
||||
|
||||
api = createAPI(credentials);
|
||||
}
|
||||
|
||||
protected abstract Moshi buildMoshi();
|
||||
|
||||
protected Retrofit getConfiguredRetrofitInstance() {
|
||||
return new Retrofit.Builder()
|
||||
.baseUrl(HttpManager.getInstance().getCredentials().getUrl() + endPoint)
|
||||
.addConverterFactory(MoshiConverterFactory.create(buildMoshi()))
|
||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
||||
.client(HttpManager.getInstance().getOkHttpClient())
|
||||
.build();
|
||||
}
|
||||
|
||||
private T createAPI(@NonNull Credentials credentials) {
|
||||
HttpManager.getInstance().setCredentials(credentials);
|
||||
retrofit = getConfiguredRetrofitInstance();
|
||||
|
||||
return retrofit.create(clazz);
|
||||
}
|
||||
|
||||
public void setCredentials(@NonNull Credentials credentials) {
|
||||
HttpManager.getInstance().setCredentials(credentials);
|
||||
|
||||
retrofit = retrofit.newBuilder()
|
||||
.baseUrl(credentials.getUrl() + endPoint)
|
||||
.build();
|
||||
|
||||
api = retrofit.create(clazz);
|
||||
}
|
||||
}
|
@ -1,14 +1,15 @@
|
||||
package com.readrops.api.services;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.readrops.db.entities.account.Account;
|
||||
import com.readrops.api.services.freshrss.FreshRSSCredentials;
|
||||
import com.readrops.api.services.freshrss.FreshRSSService;
|
||||
import com.readrops.api.services.nextcloudnews.NextNewsCredentials;
|
||||
import com.readrops.api.services.nextcloudnews.NextNewsService;
|
||||
import com.readrops.db.entities.account.Account;
|
||||
import com.readrops.db.entities.account.AccountType;
|
||||
|
||||
public abstract class Credentials {
|
||||
|
||||
private String authorization;
|
||||
private final String authorization;
|
||||
|
||||
private String url;
|
||||
|
||||
@ -25,15 +26,31 @@ public abstract class Credentials {
|
||||
return url;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public void setUrl(String url) {
|
||||
this.url = url;
|
||||
}
|
||||
|
||||
public static Credentials toCredentials(Account account) {
|
||||
String endPoint = getEndPoint(account.getAccountType());
|
||||
|
||||
switch (account.getAccountType()) {
|
||||
case NEXTCLOUD_NEWS:
|
||||
return new NextNewsCredentials(account.getLogin(), account.getPassword(), account.getUrl());
|
||||
return new NextNewsCredentials(account.getLogin(), account.getPassword(), account.getUrl() + endPoint);
|
||||
case FRESHRSS:
|
||||
return new FreshRSSCredentials(account.getToken(), account.getUrl());
|
||||
return new FreshRSSCredentials(account.getToken(), account.getUrl() + endPoint);
|
||||
default:
|
||||
return null;
|
||||
throw new IllegalArgumentException("Unknown account type");
|
||||
}
|
||||
}
|
||||
|
||||
private static String getEndPoint(AccountType accountType) {
|
||||
switch (accountType) {
|
||||
case FRESHRSS:
|
||||
return FreshRSSService.END_POINT;
|
||||
case NEXTCLOUD_NEWS:
|
||||
return NextNewsService.END_POINT;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unknown account type");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -4,13 +4,11 @@ import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.Item
|
||||
|
||||
class SyncResult {
|
||||
|
||||
var items: List<Item> = mutableListOf()
|
||||
|
||||
var feeds: List<Feed> = listOf()
|
||||
|
||||
var folders: List<Folder> = listOf()
|
||||
|
||||
var isError: Boolean = false
|
||||
}
|
||||
class SyncResult(var items: List<Item> = mutableListOf(),
|
||||
var starredItems: List<Item> = mutableListOf(),
|
||||
var feeds: List<Feed> = listOf(),
|
||||
var folders: List<Folder> = listOf(),
|
||||
var unreadIds: List<String>? = null,
|
||||
var starredIds: List<String>? = null,
|
||||
var isError: Boolean = false
|
||||
)
|
||||
|
@ -3,21 +3,15 @@ package com.readrops.api.services.freshrss;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.readrops.api.services.SyncResult;
|
||||
import com.readrops.api.services.SyncType;
|
||||
import com.readrops.api.services.freshrss.json.FreshRSSUserInfo;
|
||||
import com.readrops.db.entities.Feed;
|
||||
import com.readrops.db.entities.Folder;
|
||||
import com.readrops.db.entities.Item;
|
||||
import com.readrops.api.services.API;
|
||||
import com.readrops.api.services.Credentials;
|
||||
import com.readrops.api.services.SyncResult;
|
||||
import com.readrops.api.services.SyncType;
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSFeedsAdapter;
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSFoldersAdapter;
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSItemsAdapter;
|
||||
import com.readrops.api.services.freshrss.json.FreshRSSUserInfo;
|
||||
import com.squareup.moshi.Moshi;
|
||||
import com.squareup.moshi.Types;
|
||||
|
||||
import java.io.StringReader;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
@ -26,23 +20,21 @@ import io.reactivex.Single;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
|
||||
public class FreshRSSAPI extends API<FreshRSSService> {
|
||||
public class FreshRSSDataSource {
|
||||
|
||||
private static final int MAX_ITEMS = 5000;
|
||||
private static final int MAX_STARRED_ITEMS = 1000;
|
||||
|
||||
public static final String GOOGLE_READ = "user/-/state/com.google/read";
|
||||
public static final String GOOGLE_STARRED = "user/-/state/com.google/starred";
|
||||
public static final String GOOGLE_READING_LIST = "user/-/state/com.google/reading-list";
|
||||
|
||||
private static final String FEED_PREFIX = "feed/";
|
||||
|
||||
public FreshRSSAPI(Credentials credentials) {
|
||||
super(credentials, FreshRSSService.class, FreshRSSService.END_POINT);
|
||||
}
|
||||
private final FreshRSSService api;
|
||||
|
||||
@Override
|
||||
protected Moshi buildMoshi() {
|
||||
return new Moshi.Builder()
|
||||
.add(Types.newParameterizedType(List.class, Item.class), new FreshRSSItemsAdapter())
|
||||
.add(new FreshRSSFeedsAdapter())
|
||||
.add(new FreshRSSFoldersAdapter())
|
||||
.build();
|
||||
public FreshRSSDataSource(FreshRSSService api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -99,6 +91,7 @@ public class FreshRSSAPI extends API<FreshRSSService> {
|
||||
SyncResult syncResult = new SyncResult();
|
||||
|
||||
return setItemsReadState(syncData, writeToken)
|
||||
.andThen(setItemsStarState(syncData, writeToken))
|
||||
.andThen(getFolders()
|
||||
.flatMap(freshRSSFolders -> {
|
||||
syncResult.setFolders(freshRSSFolders);
|
||||
@ -109,7 +102,7 @@ public class FreshRSSAPI extends API<FreshRSSService> {
|
||||
syncResult.setFeeds(freshRSSFeeds);
|
||||
|
||||
if (syncType == SyncType.INITIAL_SYNC) {
|
||||
return getItems(GOOGLE_READ, MAX_ITEMS, null);
|
||||
return getItems(Arrays.asList(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null);
|
||||
} else {
|
||||
return getItems(null, MAX_ITEMS, syncData.getLastModified());
|
||||
}
|
||||
@ -117,7 +110,23 @@ public class FreshRSSAPI extends API<FreshRSSService> {
|
||||
.flatMap(freshRSSItems -> {
|
||||
syncResult.setItems(freshRSSItems);
|
||||
|
||||
return Single.just(syncResult);
|
||||
return getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS);
|
||||
}).flatMap(unreadItemsIds -> {
|
||||
syncResult.setUnreadIds(unreadItemsIds);
|
||||
|
||||
return getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS);
|
||||
}).flatMap(starredItemsIds -> {
|
||||
syncResult.setStarredIds(starredItemsIds);
|
||||
|
||||
if (syncType == SyncType.INITIAL_SYNC) {
|
||||
return getStarredItems(MAX_STARRED_ITEMS).flatMap(starredItems -> {
|
||||
syncResult.setStarredItems(starredItems);
|
||||
|
||||
return Single.just(syncResult);
|
||||
});
|
||||
} else {
|
||||
return Single.just(syncResult);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@ -142,13 +151,27 @@ public class FreshRSSAPI extends API<FreshRSSService> {
|
||||
/**
|
||||
* Fetch the items
|
||||
*
|
||||
* @param excludeTarget type of items to exclude (currently only read items)
|
||||
* @param max max number of items to fetch
|
||||
* @param lastModified fetch only items created after this timestamp
|
||||
* @param excludeTargets type of items to exclude (read items and starred items)
|
||||
* @param max max number of items to fetch
|
||||
* @param lastModified fetch only items created after this timestamp
|
||||
* @return the items
|
||||
*/
|
||||
public Single<List<Item>> getItems(@Nullable String excludeTarget, int max, @Nullable Long lastModified) {
|
||||
return api.getItems(excludeTarget, max, lastModified);
|
||||
public Single<List<Item>> getItems(@Nullable List<String> excludeTargets, int max, @Nullable Long lastModified) {
|
||||
return api.getItems(excludeTargets, max, lastModified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch starred items
|
||||
*
|
||||
* @param max max number of items to fetch
|
||||
* @return items
|
||||
*/
|
||||
public Single<List<Item>> getStarredItems(int max) {
|
||||
return api.getStarredItems(max);
|
||||
}
|
||||
|
||||
public Single<List<String>> getItemsIds(String excludeTarget, String includeTarget, int max) {
|
||||
return api.getItemsIds(excludeTarget, includeTarget, max);
|
||||
}
|
||||
|
||||
|
||||
@ -161,10 +184,27 @@ public class FreshRSSAPI extends API<FreshRSSService> {
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable markItemsReadUnread(boolean read, @NonNull List<String> itemIds, @NonNull String token) {
|
||||
if (read)
|
||||
return api.setItemsReadState(token, GOOGLE_READ, null, itemIds);
|
||||
else
|
||||
return api.setItemsReadState(token, null, GOOGLE_READ, itemIds);
|
||||
if (read) {
|
||||
return api.setItemsState(token, GOOGLE_READ, null, itemIds);
|
||||
} else {
|
||||
return api.setItemsState(token, null, GOOGLE_READ, itemIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark items as starred or unstarred
|
||||
*
|
||||
* @param starred true for starred, false for unstarred
|
||||
* @param itemIds items ids to mark
|
||||
* @param token token for modifications
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable markItemsStarredUnstarred(boolean starred, @NonNull List<String> itemIds, @NonNull String token) {
|
||||
if (starred) {
|
||||
return api.setItemsState(token, GOOGLE_STARRED, null, itemIds);
|
||||
} else {
|
||||
return api.setItemsState(token, null, GOOGLE_STARRED, itemIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -237,7 +277,7 @@ public class FreshRSSAPI extends API<FreshRSSService> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the state of items
|
||||
* Set items star state
|
||||
*
|
||||
* @param syncData data containing items to mark
|
||||
* @param token token for modifications
|
||||
@ -245,17 +285,44 @@ public class FreshRSSAPI extends API<FreshRSSService> {
|
||||
*/
|
||||
private Completable setItemsReadState(@NonNull FreshRSSSyncData syncData, @NonNull String token) {
|
||||
Completable readItemsCompletable;
|
||||
if (syncData.getReadItemsIds().isEmpty())
|
||||
if (syncData.getReadItemsIds().isEmpty()) {
|
||||
readItemsCompletable = Completable.complete();
|
||||
else
|
||||
} else {
|
||||
readItemsCompletable = markItemsReadUnread(true, syncData.getReadItemsIds(), token);
|
||||
}
|
||||
|
||||
Completable unreadItemsCompletable;
|
||||
if (syncData.getUnreadItemsIds().isEmpty())
|
||||
if (syncData.getUnreadItemsIds().isEmpty()) {
|
||||
unreadItemsCompletable = Completable.complete();
|
||||
else
|
||||
} else {
|
||||
unreadItemsCompletable = markItemsReadUnread(false, syncData.getUnreadItemsIds(), token);
|
||||
}
|
||||
|
||||
return readItemsCompletable.concatWith(unreadItemsCompletable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set items star state
|
||||
*
|
||||
* @param syncData data containing items to mark
|
||||
* @param token token for modifications
|
||||
* @return A concatenation of two completable (starred and unstarred completable)
|
||||
*/
|
||||
private Completable setItemsStarState(@NonNull FreshRSSSyncData syncData, @NonNull String token) {
|
||||
Completable starredItemsCompletable;
|
||||
if (syncData.getStarredItemsIds().isEmpty()) {
|
||||
starredItemsCompletable = Completable.complete();
|
||||
} else {
|
||||
starredItemsCompletable = markItemsStarredUnstarred(true, syncData.getStarredItemsIds(), token);
|
||||
}
|
||||
|
||||
Completable unstarredItemsCompletable;
|
||||
if (syncData.getUnstarredItemsIds().isEmpty()) {
|
||||
unstarredItemsCompletable = Completable.complete();
|
||||
} else {
|
||||
unstarredItemsCompletable = markItemsStarredUnstarred(false, syncData.getUnstarredItemsIds(), token);
|
||||
}
|
||||
|
||||
return starredItemsCompletable.concatWith(unstarredItemsCompletable);
|
||||
}
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
package com.readrops.api.services.freshrss;
|
||||
|
||||
import com.readrops.api.services.freshrss.json.FreshRSSUserInfo;
|
||||
import com.readrops.db.entities.Feed;
|
||||
import com.readrops.db.entities.Folder;
|
||||
import com.readrops.db.entities.Item;
|
||||
import com.readrops.api.services.freshrss.json.FreshRSSUserInfo;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@ -35,14 +35,20 @@ public interface FreshRSSService {
|
||||
Single<List<Feed>> getFeeds();
|
||||
|
||||
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list")
|
||||
Single<List<Item>> getItems(@Query("xt") String excludeTarget, @Query("n") int max, @Query("ot") Long lastModified);
|
||||
Single<List<Item>> getItems(@Query("xt") List<String> excludeTarget, @Query("n") int max, @Query("ot") Long lastModified);
|
||||
|
||||
@GET("reader/api/0/stream/contents/user/-/state/com.google/starred")
|
||||
Single<List<Item>> getStarredItems(@Query("n") int max);
|
||||
|
||||
@GET("reader/api/0/stream/items/ids")
|
||||
Single<List<String>> getItemsIds(@Query("xt") String excludeTarget, @Query("s") String includeTarget, @Query("n") int max);
|
||||
|
||||
@GET("reader/api/0/tag/list?output=json")
|
||||
Single<List<Folder>> getFolders();
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/edit-tag")
|
||||
Completable setItemsReadState(@Field("T") String token, @Field("a") String readAction, @Field("r") String unreadAction, @Field("i") List<String> itemIds);
|
||||
Completable setItemsState(@Field("T") String token, @Field("a") String addAction, @Field("r") String removeAction, @Field("i") List<String> itemIds);
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/subscription/edit")
|
||||
|
@ -11,6 +11,10 @@ public class FreshRSSSyncData {
|
||||
|
||||
private List<String> unreadItemsIds;
|
||||
|
||||
private List<String> starredItemsIds;
|
||||
|
||||
private List<String> unstarredItemsIds;
|
||||
|
||||
public FreshRSSSyncData() {
|
||||
readItemsIds = new ArrayList<>();
|
||||
unreadItemsIds = new ArrayList<>();
|
||||
@ -39,4 +43,20 @@ public class FreshRSSSyncData {
|
||||
public void setUnreadItemsIds(List<String> unreadItemsIds) {
|
||||
this.unreadItemsIds = unreadItemsIds;
|
||||
}
|
||||
|
||||
public List<String> getStarredItemsIds() {
|
||||
return starredItemsIds;
|
||||
}
|
||||
|
||||
public void setStarredItemsIds(List<String> starredItemsIds) {
|
||||
this.starredItemsIds = starredItemsIds;
|
||||
}
|
||||
|
||||
public List<String> getUnstarredItemsIds() {
|
||||
return unstarredItemsIds;
|
||||
}
|
||||
|
||||
public void setUnstarredItemsIds(List<String> unstarredItemsIds) {
|
||||
this.unstarredItemsIds = unstarredItemsIds;
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
package com.readrops.api.services.freshrss.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonReader
|
||||
@ -16,36 +17,40 @@ class FreshRSSFeedsAdapter {
|
||||
fun fromJson(reader: JsonReader): List<Feed> {
|
||||
val feeds = mutableListOf<Feed>()
|
||||
|
||||
reader.beginObject()
|
||||
reader.nextName() // "subscriptions", beginning of the feed array
|
||||
reader.beginArray()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
return try {
|
||||
reader.beginObject()
|
||||
reader.nextName() // "subscriptions", beginning of the feed array
|
||||
reader.beginArray()
|
||||
|
||||
val feed = Feed()
|
||||
while (reader.hasNext()) {
|
||||
with(feed) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> name = reader.nextString()
|
||||
1 -> url = reader.nextString()
|
||||
2 -> siteUrl = reader.nextString()
|
||||
3 -> iconUrl = reader.nextString()
|
||||
4 -> remoteId = reader.nextString()
|
||||
5 -> remoteFolderId = getCategoryId(reader)
|
||||
else -> reader.skipValue()
|
||||
reader.beginObject()
|
||||
|
||||
val feed = Feed()
|
||||
while (reader.hasNext()) {
|
||||
with(feed) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> name = reader.nextString()
|
||||
1 -> url = reader.nextString()
|
||||
2 -> siteUrl = reader.nextString()
|
||||
3 -> iconUrl = reader.nextString()
|
||||
4 -> remoteId = reader.nextString()
|
||||
5 -> remoteFolderId = getCategoryId(reader)
|
||||
else -> reader.skipValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
feeds += feed
|
||||
reader.endObject()
|
||||
}
|
||||
|
||||
feeds += feed
|
||||
reader.endArray()
|
||||
reader.endObject()
|
||||
|
||||
feeds
|
||||
} catch (e: Exception) {
|
||||
throw ParseException(e.message)
|
||||
}
|
||||
|
||||
reader.endArray()
|
||||
reader.endObject()
|
||||
|
||||
return feeds
|
||||
}
|
||||
|
||||
private fun getCategoryId(reader: JsonReader): String? {
|
||||
@ -72,6 +77,7 @@ class FreshRSSFeedsAdapter {
|
||||
}
|
||||
|
||||
companion object {
|
||||
val NAMES: JsonReader.Options = JsonReader.Options.of("title", "url", "htmlUrl", "iconUrl", "id", "categories")
|
||||
val NAMES: JsonReader.Options = JsonReader.Options.of("title", "url", "htmlUrl",
|
||||
"iconUrl", "id", "categories")
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package com.readrops.api.services.freshrss.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonReader
|
||||
@ -17,42 +18,46 @@ class FreshRSSFoldersAdapter {
|
||||
fun fromJson(reader: JsonReader): List<Folder> {
|
||||
val folders = mutableListOf<Folder>()
|
||||
|
||||
reader.beginObject()
|
||||
reader.nextName() // "tags", beginning of folder array
|
||||
reader.beginArray()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
return try {
|
||||
reader.beginObject()
|
||||
|
||||
val folder = Folder()
|
||||
var type: String? = null
|
||||
reader.nextName() // "tags", beginning of folder array
|
||||
reader.beginArray()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
with(folder) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> {
|
||||
val id = reader.nextString()
|
||||
name = StringTokenizer(id, "/")
|
||||
.toList()
|
||||
.last() as String
|
||||
remoteId = id
|
||||
reader.beginObject()
|
||||
|
||||
val folder = Folder()
|
||||
var type: String? = null
|
||||
|
||||
while (reader.hasNext()) {
|
||||
with(folder) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> {
|
||||
val id = reader.nextString()
|
||||
name = StringTokenizer(id, "/")
|
||||
.toList()
|
||||
.last() as String
|
||||
remoteId = id
|
||||
}
|
||||
1 -> type = reader.nextString()
|
||||
else -> reader.skipValue()
|
||||
}
|
||||
1 -> type = reader.nextString()
|
||||
else -> reader.skipValue()
|
||||
}
|
||||
}
|
||||
|
||||
if (type == "folder") // add only folders and avoid tags
|
||||
folders += folder
|
||||
|
||||
reader.endObject()
|
||||
}
|
||||
|
||||
if (type == "folder") // add only folders and avoid tags
|
||||
folders += folder
|
||||
|
||||
reader.endArray()
|
||||
reader.endObject()
|
||||
|
||||
folders
|
||||
} catch (e: Exception) {
|
||||
throw ParseException(e.message)
|
||||
}
|
||||
|
||||
reader.endArray()
|
||||
reader.endObject()
|
||||
|
||||
return folders
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -1,8 +1,10 @@
|
||||
package com.readrops.api.services.freshrss.adapters
|
||||
|
||||
import android.util.TimingLogger
|
||||
import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_READ
|
||||
import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_STARRED
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.api.services.freshrss.FreshRSSAPI.GOOGLE_READ
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonWriter
|
||||
@ -19,17 +21,21 @@ class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
||||
val logger = TimingLogger(TAG, "item parsing")
|
||||
val items = mutableListOf<Item>()
|
||||
|
||||
reader.beginObject()
|
||||
while (reader.hasNext()) {
|
||||
if (reader.nextName() == "items") parseItems(reader, items) else reader.skipValue()
|
||||
return try {
|
||||
reader.beginObject()
|
||||
while (reader.hasNext()) {
|
||||
if (reader.nextName() == "items") parseItems(reader, items) else reader.skipValue()
|
||||
}
|
||||
|
||||
reader.endObject()
|
||||
|
||||
logger.addSplit("item parsing done")
|
||||
logger.dumpToLog()
|
||||
|
||||
items
|
||||
} catch (e: Exception) {
|
||||
throw ParseException(e.message)
|
||||
}
|
||||
|
||||
reader.endObject()
|
||||
|
||||
logger.addSplit("item parsing done")
|
||||
logger.dumpToLog()
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private fun parseItems(reader: JsonReader, items: MutableList<Item>) {
|
||||
@ -48,7 +54,7 @@ class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
||||
2 -> title = reader.nextString()
|
||||
3 -> content = getContent(reader)
|
||||
4 -> link = getLink(reader)
|
||||
5 -> isRead = getReadState(reader)
|
||||
5 -> getStates(reader, this)
|
||||
6 -> feedRemoteId = getRemoteFeedId(reader)
|
||||
7 -> author = reader.nextString()
|
||||
else -> reader.skipValue()
|
||||
@ -97,18 +103,17 @@ class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
||||
return href
|
||||
}
|
||||
|
||||
private fun getReadState(reader: JsonReader): Boolean {
|
||||
var isRead = false
|
||||
private fun getStates(reader: JsonReader, item: Item) {
|
||||
reader.beginArray()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
when (reader.nextString()) {
|
||||
GOOGLE_READ -> isRead = true
|
||||
GOOGLE_READ -> item.isRead = true
|
||||
GOOGLE_STARRED -> item.isStarred = true
|
||||
}
|
||||
}
|
||||
|
||||
reader.endArray()
|
||||
return isRead
|
||||
}
|
||||
|
||||
private fun getRemoteFeedId(reader: JsonReader): String? {
|
||||
@ -127,7 +132,8 @@ class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "published", "title", "summary", "alternate", "categories", "origin", "author")
|
||||
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "published", "title",
|
||||
"summary", "alternate", "categories", "origin", "author")
|
||||
|
||||
val TAG = FreshRSSItemsAdapter::class.java.simpleName
|
||||
}
|
||||
|
@ -0,0 +1,58 @@
|
||||
package com.readrops.api.services.freshrss.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonWriter
|
||||
|
||||
class FreshRSSItemsIdsAdapter : JsonAdapter<List<String>>() {
|
||||
|
||||
override fun toJson(writer: JsonWriter, value: List<String>?) {
|
||||
// not useful here
|
||||
}
|
||||
|
||||
@SuppressLint("CheckResult")
|
||||
override fun fromJson(reader: JsonReader): List<String>? = with(reader) {
|
||||
val ids = arrayListOf<String>()
|
||||
|
||||
return try {
|
||||
beginObject()
|
||||
nextName()
|
||||
beginArray()
|
||||
|
||||
while (hasNext()) {
|
||||
beginObject()
|
||||
|
||||
when (nextName()) {
|
||||
"id" -> {
|
||||
val value = nextNonEmptyString()
|
||||
ids += "tag:google.com,2005:reader/item/${
|
||||
value.toLong()
|
||||
.toString(16).padStart(value.length, '0')
|
||||
}"
|
||||
}
|
||||
else -> skipValue()
|
||||
}
|
||||
|
||||
endObject()
|
||||
}
|
||||
|
||||
endArray()
|
||||
|
||||
// skip continuation
|
||||
if (hasNext()) {
|
||||
skipName()
|
||||
skipValue()
|
||||
}
|
||||
|
||||
endObject()
|
||||
|
||||
ids
|
||||
} catch (e: Exception) {
|
||||
throw ParseException(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -5,6 +5,6 @@ import com.readrops.api.services.Credentials;
|
||||
public class NextNewsCredentials extends Credentials {
|
||||
|
||||
public NextNewsCredentials(String login, String password, String url) {
|
||||
super(okhttp3.Credentials.basic(login, password), url);
|
||||
super(login != null && password != null ? okhttp3.Credentials.basic(login, password) : null, url);
|
||||
}
|
||||
}
|
||||
|
@ -5,56 +5,52 @@ import android.content.res.Resources;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.readrops.api.services.SyncResult;
|
||||
import com.readrops.api.services.SyncType;
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsUserAdapter;
|
||||
import com.readrops.api.utils.ApiUtils;
|
||||
import com.readrops.api.utils.exceptions.ConflictException;
|
||||
import com.readrops.api.utils.exceptions.UnknownFormatException;
|
||||
import com.readrops.db.entities.Feed;
|
||||
import com.readrops.db.entities.Folder;
|
||||
import com.readrops.db.entities.Item;
|
||||
import com.readrops.api.services.API;
|
||||
import com.readrops.api.services.Credentials;
|
||||
import com.readrops.api.services.SyncResult;
|
||||
import com.readrops.api.services.SyncType;
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFeedsAdapter;
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFoldersAdapter;
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsItemsAdapter;
|
||||
import com.readrops.api.services.nextcloudnews.json.NextNewsUser;
|
||||
import com.readrops.api.utils.ConflictException;
|
||||
import com.readrops.api.utils.LibUtils;
|
||||
import com.readrops.api.utils.UnknownFormatException;
|
||||
import com.squareup.moshi.Moshi;
|
||||
import com.squareup.moshi.Types;
|
||||
import com.readrops.db.pojo.StarItem;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import okhttp3.ResponseBody;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class NextNewsAPI extends API<NextNewsService> {
|
||||
public class NextNewsDataSource {
|
||||
|
||||
private static final String TAG = NextNewsAPI.class.getSimpleName();
|
||||
private static final String TAG = NextNewsDataSource.class.getSimpleName();
|
||||
|
||||
public NextNewsAPI(Credentials credentials) {
|
||||
super(credentials, NextNewsService.class, NextNewsService.END_POINT);
|
||||
}
|
||||
private static final int MAX_ITEMS = 5000;
|
||||
private static final int MAX_STARRED_ITEMS = 1000;
|
||||
|
||||
@Override
|
||||
protected Moshi buildMoshi() {
|
||||
return new Moshi.Builder()
|
||||
.add(new NextNewsFeedsAdapter())
|
||||
.add(new NextNewsFoldersAdapter())
|
||||
.add(Types.newParameterizedType(List.class, Item.class), new NextNewsItemsAdapter())
|
||||
.build();
|
||||
private NextNewsService api;
|
||||
|
||||
public NextNewsDataSource(NextNewsService api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public NextNewsUser login() throws IOException {
|
||||
Response<NextNewsUser> response = api.getUser().execute();
|
||||
public String login(String user) throws IOException {
|
||||
Response<ResponseBody> response = api.getUser(user).execute();
|
||||
|
||||
if (!response.isSuccessful())
|
||||
if (!response.isSuccessful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.body();
|
||||
String displayName = new NextNewsUserAdapter().fromXml(response.body().byteStream());
|
||||
response.body().close();
|
||||
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@ -62,7 +58,7 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
Response<List<Feed>> response = api.createFeed(url, folderId).execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
if (response.code() == LibUtils.HTTP_UNPROCESSABLE)
|
||||
if (response.code() == ApiUtils.HTTP_UNPROCESSABLE)
|
||||
throw new UnknownFormatException();
|
||||
else
|
||||
return null;
|
||||
@ -91,7 +87,8 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
private void initialSync(SyncResult syncResult) throws IOException {
|
||||
getFeedsAndFolders(syncResult);
|
||||
|
||||
Response<List<Item>> itemsResponse = api.getItems(3, false, MAX_ITEMS).execute();
|
||||
// unread items
|
||||
Response<List<Item>> itemsResponse = api.getItems(ItemQueryType.ALL.value, false, MAX_ITEMS).execute();
|
||||
List<Item> itemList = itemsResponse.body();
|
||||
|
||||
if (!itemsResponse.isSuccessful())
|
||||
@ -99,13 +96,23 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
|
||||
if (itemList != null)
|
||||
syncResult.setItems(itemList);
|
||||
|
||||
// starred items
|
||||
Response<List<Item>> starredItemsResponse = api.getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS).execute();
|
||||
List<Item> starredItems = starredItemsResponse.body();
|
||||
|
||||
if (!itemsResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
|
||||
if (itemList != null)
|
||||
syncResult.setStarredItems(starredItems);
|
||||
}
|
||||
|
||||
private void classicSync(SyncResult syncResult, NextNewsSyncData data) throws IOException {
|
||||
putModifiedItems(data, syncResult);
|
||||
getFeedsAndFolders(syncResult);
|
||||
|
||||
Response<List<Item>> itemsResponse = api.getNewItems(data.getLastModified(), 3).execute();
|
||||
Response<List<Item>> itemsResponse = api.getNewItems(data.getLastModified(), ItemQueryType.ALL.value).execute();
|
||||
List<Item> itemList = itemsResponse.body();
|
||||
|
||||
if (!itemsResponse.isSuccessful())
|
||||
@ -137,27 +144,11 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
}
|
||||
|
||||
private void putModifiedItems(NextNewsSyncData data, SyncResult syncResult) throws IOException {
|
||||
if (!data.getReadItems().isEmpty()) {
|
||||
Map<String, List<String>> itemIdsMap = new HashMap<>();
|
||||
itemIdsMap.put("items", data.getReadItems());
|
||||
setReadState(data.getReadItems(), syncResult, StateType.READ);
|
||||
setReadState(data.getUnreadItems(), syncResult, StateType.UNREAD);
|
||||
|
||||
Response readItemsResponse = api.setArticlesState(StateType.READ.name().toLowerCase(),
|
||||
itemIdsMap).execute();
|
||||
|
||||
if (!readItemsResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
}
|
||||
|
||||
if (!data.getUnreadItems().isEmpty()) {
|
||||
Map<String, List<String>> itemIdsMap = new HashMap<>();
|
||||
itemIdsMap.put("items", data.getUnreadItems());
|
||||
|
||||
Response unreadItemsResponse = api.setArticlesState(StateType.UNREAD.toString().toLowerCase(),
|
||||
itemIdsMap).execute();
|
||||
|
||||
if (!unreadItemsResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
}
|
||||
setStarState(data.getStarredItems(), syncResult, StateType.STAR);
|
||||
setStarState(data.getUnstarredItems(), syncResult, StateType.UNSTAR);
|
||||
}
|
||||
|
||||
public List<Folder> createFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException {
|
||||
@ -168,9 +159,9 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
|
||||
if (foldersResponse.isSuccessful())
|
||||
return foldersResponse.body();
|
||||
else if (foldersResponse.code() == LibUtils.HTTP_UNPROCESSABLE)
|
||||
else if (foldersResponse.code() == ApiUtils.HTTP_UNPROCESSABLE)
|
||||
throw new UnknownFormatException();
|
||||
else if (foldersResponse.code() == LibUtils.HTTP_CONFLICT)
|
||||
else if (foldersResponse.code() == ApiUtils.HTTP_CONFLICT)
|
||||
throw new ConflictException();
|
||||
else
|
||||
return new ArrayList<>();
|
||||
@ -181,7 +172,7 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
|
||||
if (response.isSuccessful())
|
||||
return true;
|
||||
else if (response.code() == LibUtils.HTTP_NOT_FOUND)
|
||||
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
|
||||
throw new Resources.NotFoundException();
|
||||
else
|
||||
return false;
|
||||
@ -197,11 +188,11 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
return true;
|
||||
else {
|
||||
switch (response.code()) {
|
||||
case LibUtils.HTTP_NOT_FOUND:
|
||||
case ApiUtils.HTTP_NOT_FOUND:
|
||||
throw new Resources.NotFoundException();
|
||||
case LibUtils.HTTP_UNPROCESSABLE:
|
||||
case ApiUtils.HTTP_UNPROCESSABLE:
|
||||
throw new UnknownFormatException();
|
||||
case LibUtils.HTTP_CONFLICT:
|
||||
case ApiUtils.HTTP_CONFLICT:
|
||||
throw new ConflictException();
|
||||
default:
|
||||
return false;
|
||||
@ -214,7 +205,7 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
|
||||
if (response.isSuccessful())
|
||||
return true;
|
||||
else if (response.code() == LibUtils.HTTP_NOT_FOUND)
|
||||
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
|
||||
throw new Resources.NotFoundException();
|
||||
else
|
||||
return false;
|
||||
@ -228,7 +219,7 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
|
||||
if (response.isSuccessful())
|
||||
return true;
|
||||
else if (response.code() == LibUtils.HTTP_NOT_FOUND)
|
||||
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
|
||||
throw new Resources.NotFoundException();
|
||||
else
|
||||
return false;
|
||||
@ -242,16 +233,63 @@ public class NextNewsAPI extends API<NextNewsService> {
|
||||
|
||||
if (response.isSuccessful())
|
||||
return true;
|
||||
else if (response.code() == LibUtils.HTTP_NOT_FOUND)
|
||||
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
|
||||
throw new Resources.NotFoundException();
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
private void setReadState(List<String> items, SyncResult syncResult, StateType stateType) throws IOException {
|
||||
if (!items.isEmpty()) {
|
||||
Map<String, List<String>> itemIdsMap = new HashMap<>();
|
||||
itemIdsMap.put("items", items);
|
||||
|
||||
Response readItemsResponse = api.setReadState(stateType.name().toLowerCase(),
|
||||
itemIdsMap).execute();
|
||||
|
||||
if (!readItemsResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void setStarState(List<StarItem> items, SyncResult syncResult, StateType stateType) throws IOException {
|
||||
if (!items.isEmpty()) {
|
||||
List<Map<String, String>> body = new ArrayList<>();
|
||||
for (StarItem item : items) {
|
||||
Map<String, String> itemBody = new HashMap<>();
|
||||
itemBody.put("feedId", item.getFeedRemoteId());
|
||||
itemBody.put("guidHash", item.getGuidHash());
|
||||
|
||||
body.add(itemBody);
|
||||
}
|
||||
|
||||
Response response = api.setStarState(stateType.name().toLowerCase(),
|
||||
Collections.singletonMap("items", body)).execute();
|
||||
if (!response.isSuccessful()) {
|
||||
syncResult.setError(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum StateType {
|
||||
READ,
|
||||
UNREAD,
|
||||
STARRED,
|
||||
UNSTARRED
|
||||
STAR,
|
||||
UNSTAR
|
||||
}
|
||||
|
||||
public enum ItemQueryType {
|
||||
ALL(3),
|
||||
STARRED(2);
|
||||
|
||||
private int value;
|
||||
|
||||
ItemQueryType(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public int getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
@ -3,7 +3,6 @@ package com.readrops.api.services.nextcloudnews;
|
||||
import com.readrops.db.entities.Feed;
|
||||
import com.readrops.db.entities.Folder;
|
||||
import com.readrops.db.entities.Item;
|
||||
import com.readrops.api.services.nextcloudnews.json.NextNewsUser;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@ -13,6 +12,7 @@ import retrofit2.Call;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.DELETE;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Headers;
|
||||
import retrofit2.http.POST;
|
||||
import retrofit2.http.PUT;
|
||||
import retrofit2.http.Path;
|
||||
@ -21,9 +21,10 @@ import retrofit2.http.Query;
|
||||
public interface NextNewsService {
|
||||
|
||||
String END_POINT = "/index.php/apps/news/api/v1-2/";
|
||||
|
||||
@GET("user")
|
||||
Call<NextNewsUser> getUser();
|
||||
|
||||
@GET("/ocs/v1.php/cloud/users/{userId}")
|
||||
@Headers("OCS-APIRequest: true")
|
||||
Call<ResponseBody> getUser(@Path("userId") String userId);
|
||||
|
||||
@GET("folders")
|
||||
Call<List<Folder>> getFolders();
|
||||
@ -38,7 +39,10 @@ public interface NextNewsService {
|
||||
Call<List<Item>> getNewItems(@Query("lastModified") long lastModified, @Query("type") int type);
|
||||
|
||||
@PUT("items/{stateType}/multiple")
|
||||
Call<ResponseBody> setArticlesState(@Path("stateType") String stateType, @Body Map<String, List<String>> itemIdsMap);
|
||||
Call<ResponseBody> setReadState(@Path("stateType") String stateType, @Body Map<String, List<String>> itemIdsMap);
|
||||
|
||||
@PUT("items/{starType}/multiple")
|
||||
Call<ResponseBody> setStarState(@Path("starType") String starType, @Body Map<String, List<Map<String, String>>> body);
|
||||
|
||||
@POST("feeds")
|
||||
Call<List<Feed>> createFeed(@Query("url") String url, @Query("folderId") int folderId);
|
||||
|
@ -1,5 +1,7 @@
|
||||
package com.readrops.api.services.nextcloudnews;
|
||||
|
||||
import com.readrops.db.pojo.StarItem;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
@ -9,9 +11,9 @@ public class NextNewsSyncData {
|
||||
|
||||
private List<String> readItems;
|
||||
|
||||
private List<Integer> starredItems;
|
||||
private List<StarItem> starredItems;
|
||||
|
||||
private List<Integer> unstarredItems;
|
||||
private List<StarItem> unstarredItems;
|
||||
|
||||
private long lastModified;
|
||||
|
||||
@ -38,19 +40,19 @@ public class NextNewsSyncData {
|
||||
this.readItems = readItems;
|
||||
}
|
||||
|
||||
public List<Integer> getStarredItems() {
|
||||
public List<StarItem> getStarredItems() {
|
||||
return starredItems;
|
||||
}
|
||||
|
||||
public void setStarredItems(List<Integer> starredItems) {
|
||||
public void setStarredItems(List<StarItem> starredItems) {
|
||||
this.starredItems = starredItems;
|
||||
}
|
||||
|
||||
public List<Integer> getUnstarredItems() {
|
||||
public List<StarItem> getUnstarredItems() {
|
||||
return unstarredItems;
|
||||
}
|
||||
|
||||
public void setUnstarredItems(List<Integer> unstarredItems) {
|
||||
public void setUnstarredItems(List<StarItem> unstarredItems) {
|
||||
this.unstarredItems = unstarredItems;
|
||||
}
|
||||
|
||||
|
@ -1,12 +1,15 @@
|
||||
package com.readrops.api.services.nextcloudnews.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import com.readrops.api.utils.nextNullableInt
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||
import com.readrops.api.utils.extensions.nextNullableInt
|
||||
import com.readrops.api.utils.extensions.nextNullableString
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.api.utils.nextNullableString
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.ToJson
|
||||
import java.net.URI
|
||||
|
||||
class NextNewsFeedsAdapter {
|
||||
|
||||
@ -18,15 +21,19 @@ class NextNewsFeedsAdapter {
|
||||
fun fromJson(reader: JsonReader): List<Feed> {
|
||||
val feeds = mutableListOf<Feed>()
|
||||
|
||||
reader.beginObject()
|
||||
return try {
|
||||
reader.beginObject()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
if (reader.nextName() == "feeds") parseFeeds(reader, feeds) else reader.skipValue()
|
||||
while (reader.hasNext()) {
|
||||
if (reader.nextName() == "feeds") parseFeeds(reader, feeds) else reader.skipValue()
|
||||
}
|
||||
|
||||
reader.endObject()
|
||||
|
||||
feeds
|
||||
} catch (e: Exception) {
|
||||
throw ParseException(e.message)
|
||||
}
|
||||
|
||||
reader.endObject()
|
||||
|
||||
return feeds
|
||||
}
|
||||
|
||||
private fun parseFeeds(reader: JsonReader, feeds: MutableList<Feed>) {
|
||||
@ -39,10 +46,10 @@ class NextNewsFeedsAdapter {
|
||||
while (reader.hasNext()) {
|
||||
with(feed) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> remoteId = reader.nextString()
|
||||
1 -> url = reader.nextString()
|
||||
2 -> name = reader.nextString()
|
||||
3 -> iconUrl = reader.nextString()
|
||||
0 -> remoteId = reader.nextNonEmptyString()
|
||||
1 -> url = reader.nextNonEmptyString()
|
||||
2 -> name = reader.nextNullableString()
|
||||
3 -> iconUrl = reader.nextNullableString()
|
||||
4 -> {
|
||||
val nextInt = reader.nextNullableInt()
|
||||
remoteFolderId = if (nextInt != null && nextInt > 0) nextInt.toString() else null
|
||||
@ -53,6 +60,8 @@ class NextNewsFeedsAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
if (feed.name == null) feed.name = URI.create(feed.url).host
|
||||
|
||||
feeds += feed
|
||||
reader.endObject()
|
||||
}
|
||||
@ -61,6 +70,7 @@ class NextNewsFeedsAdapter {
|
||||
}
|
||||
|
||||
companion object {
|
||||
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title", "faviconLink", "folderId", "link")
|
||||
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title",
|
||||
"faviconLink", "folderId", "link")
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package com.readrops.api.services.nextcloudnews.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonReader
|
||||
@ -16,32 +17,36 @@ class NextNewsFoldersAdapter {
|
||||
fun fromJson(reader: JsonReader): List<Folder> {
|
||||
val folders = mutableListOf<Folder>()
|
||||
|
||||
reader.beginObject()
|
||||
reader.nextName() // "folders", beginning of folders array
|
||||
reader.beginArray()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
val folder = Folder()
|
||||
return try {
|
||||
reader.beginObject()
|
||||
reader.nextName() // "folders", beginning of folders array
|
||||
reader.beginArray()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
with(folder) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> remoteId = reader.nextInt().toString()
|
||||
1 -> name = reader.nextString()
|
||||
else -> reader.skipValue()
|
||||
val folder = Folder()
|
||||
reader.beginObject()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
with(folder) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> remoteId = reader.nextInt().toString()
|
||||
1 -> name = reader.nextString()
|
||||
else -> reader.skipValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
folders += folder
|
||||
reader.endObject()
|
||||
}
|
||||
|
||||
folders += folder
|
||||
reader.endArray()
|
||||
reader.endObject()
|
||||
|
||||
folders
|
||||
} catch (e: Exception) {
|
||||
throw ParseException(e.message)
|
||||
}
|
||||
|
||||
reader.endArray()
|
||||
reader.endObject()
|
||||
|
||||
return folders
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -2,8 +2,10 @@ package com.readrops.api.services.nextcloudnews.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.api.utils.LibUtils
|
||||
import com.readrops.api.utils.nextNullableString
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||
import com.readrops.api.utils.extensions.nextNullableString
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonWriter
|
||||
@ -21,50 +23,56 @@ class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
|
||||
override fun fromJson(reader: JsonReader): List<Item> {
|
||||
val items = mutableListOf<Item>()
|
||||
|
||||
reader.beginObject()
|
||||
reader.nextName() // "items", beginning of items array
|
||||
reader.beginArray()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
val item = Item()
|
||||
return try {
|
||||
reader.beginObject()
|
||||
|
||||
var enclosureMime: String? = null
|
||||
var enclosureLink: String? = null
|
||||
reader.nextName() // "items", beginning of items array
|
||||
reader.beginArray()
|
||||
|
||||
while (reader.hasNext()) {
|
||||
with(item) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> remoteId = reader.nextInt().toString()
|
||||
1 -> link = reader.nextNullableString()
|
||||
2 -> title = reader.nextString()
|
||||
3 -> author = reader.nextString()
|
||||
4 -> pubDate = LocalDateTime(reader.nextLong() * 1000L, DateTimeZone.getDefault())
|
||||
5 -> content = reader.nextString()
|
||||
6 -> enclosureMime = reader.nextNullableString()
|
||||
7 -> enclosureLink = reader.nextNullableString()
|
||||
8 -> feedRemoteId = reader.nextInt().toString()
|
||||
9 -> isRead = !reader.nextBoolean()
|
||||
else -> reader.skipValue()
|
||||
val item = Item()
|
||||
reader.beginObject()
|
||||
|
||||
var enclosureMime: String? = null
|
||||
var enclosureLink: String? = null
|
||||
|
||||
while (reader.hasNext()) {
|
||||
with(item) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> remoteId = reader.nextInt().toString()
|
||||
1 -> link = reader.nextNullableString()
|
||||
2 -> title = reader.nextString()
|
||||
3 -> author = reader.nextString()
|
||||
4 -> pubDate = LocalDateTime(reader.nextLong() * 1000L, DateTimeZone.getDefault())
|
||||
5 -> content = reader.nextString()
|
||||
6 -> enclosureMime = reader.nextNullableString()
|
||||
7 -> enclosureLink = reader.nextNullableString()
|
||||
8 -> feedRemoteId = reader.nextInt().toString()
|
||||
9 -> isRead = !reader.nextBoolean() // the negation is important here
|
||||
10 -> isStarred = reader.nextBoolean()
|
||||
11 -> guid = reader.nextNullableString()
|
||||
else -> reader.skipValue()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enclosureMime != null && ApiUtils.isMimeImage(enclosureMime!!))
|
||||
item.imageLink = enclosureLink
|
||||
|
||||
items += item
|
||||
reader.endObject()
|
||||
}
|
||||
|
||||
if (enclosureMime != null && LibUtils.isMimeImage(enclosureMime!!))
|
||||
item.imageLink = enclosureLink
|
||||
|
||||
items += item
|
||||
reader.endArray()
|
||||
reader.endObject()
|
||||
|
||||
items
|
||||
} catch (e: Exception) {
|
||||
throw ParseException(e.message)
|
||||
}
|
||||
|
||||
reader.endArray()
|
||||
reader.endObject()
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
companion object {
|
||||
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title", "author",
|
||||
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread")
|
||||
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred", "guidHash")
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package com.readrops.api.services.nextcloudnews.adapters
|
||||
|
||||
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
|
||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||
import com.readrops.api.localfeed.XmlAdapter
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import java.io.InputStream
|
||||
|
||||
class NextNewsUserAdapter : XmlAdapter<String> {
|
||||
|
||||
override fun fromXml(inputStream: InputStream): String {
|
||||
val konsumer = inputStream.konsumeXml()
|
||||
var displayName: String? = null
|
||||
|
||||
return try {
|
||||
konsumer.child("ocs") {
|
||||
allChildrenAutoIgnore("data") {
|
||||
allChildrenAutoIgnore("displayname") {
|
||||
displayName = nonNullText()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
konsumer.close()
|
||||
displayName!!
|
||||
} catch (e: Exception) {
|
||||
throw ParseException(e.message)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,14 +0,0 @@
|
||||
package com.readrops.api.services.nextcloudnews.json
|
||||
|
||||
import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class NextNewsUser(val userId: String,
|
||||
val displayName: String,
|
||||
val lastLoginTimestamp: Long,
|
||||
val avatar: Avatar?) {
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class Avatar(val data: String,
|
||||
val mime: String)
|
||||
}
|
53
api/src/main/java/com/readrops/api/utils/ApiUtils.java
Normal file
53
api/src/main/java/com/readrops/api/utils/ApiUtils.java
Normal file
@ -0,0 +1,53 @@
|
||||
package com.readrops.api.utils;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.jsoup.Jsoup;
|
||||
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
public final class ApiUtils {
|
||||
|
||||
public static final String HTML_CONTENT_TYPE = "text/html";
|
||||
|
||||
public static final String CONTENT_TYPE_HEADER = "content-type";
|
||||
public static final String ETAG_HEADER = "ETag";
|
||||
public static final String IF_NONE_MATCH_HEADER = "If-None-Match";
|
||||
public static final String LAST_MODIFIED_HEADER = "Last-Modified";
|
||||
public static final String IF_MODIFIED_HEADER = "If-Modified-Since";
|
||||
|
||||
public static final int HTTP_UNPROCESSABLE = 422;
|
||||
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 boolean isMimeImage(@NonNull String type) {
|
||||
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();
|
||||
}
|
||||
}
|
21
api/src/main/java/com/readrops/api/utils/AuthInterceptor.kt
Normal file
21
api/src/main/java/com/readrops/api/utils/AuthInterceptor.kt
Normal file
@ -0,0 +1,21 @@
|
||||
package com.readrops.api.utils
|
||||
|
||||
import com.readrops.api.services.Credentials
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.net.URI
|
||||
|
||||
class AuthInterceptor(var credentials: Credentials? = null) : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val requestBuilder = chain.request().newBuilder()
|
||||
|
||||
credentials?.let {
|
||||
if (it.authorization != null) {
|
||||
requestBuilder.addHeader("Authorization", it.authorization)
|
||||
}
|
||||
}
|
||||
|
||||
return chain.proceed(requestBuilder.build())
|
||||
}
|
||||
}
|
76
api/src/main/java/com/readrops/api/utils/DateUtils.java
Normal file
76
api/src/main/java/com/readrops/api/utils/DateUtils.java
Normal 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);
|
||||
}
|
||||
}
|
@ -1,77 +0,0 @@
|
||||
package com.readrops.api.utils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.readrops.api.services.Credentials;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
public class HttpManager {
|
||||
|
||||
private OkHttpClient okHttpClient;
|
||||
private Credentials credentials;
|
||||
|
||||
public HttpManager() {
|
||||
buildOkHttp();
|
||||
}
|
||||
|
||||
private void buildOkHttp() {
|
||||
okHttpClient = new OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.addInterceptor(new AuthInterceptor())
|
||||
.build();
|
||||
}
|
||||
|
||||
public OkHttpClient getOkHttpClient() {
|
||||
return okHttpClient;
|
||||
}
|
||||
|
||||
public void setCredentials(@Nullable Credentials credentials) {
|
||||
this.credentials = credentials;
|
||||
}
|
||||
|
||||
public Credentials getCredentials() {
|
||||
return credentials;
|
||||
}
|
||||
|
||||
private static HttpManager instance;
|
||||
|
||||
public static HttpManager getInstance() {
|
||||
if (instance == null) {
|
||||
instance = new HttpManager();
|
||||
}
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
public static void setInstance(OkHttpClient client) {
|
||||
instance.okHttpClient = client;
|
||||
}
|
||||
|
||||
public class AuthInterceptor implements Interceptor {
|
||||
|
||||
public AuthInterceptor() {
|
||||
// empty constructor
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response intercept(Chain chain) throws IOException {
|
||||
Request request = chain.request();
|
||||
|
||||
if (credentials != null && credentials.getAuthorization() != null) {
|
||||
request = request.newBuilder()
|
||||
.addHeader("Authorization", credentials.getAuthorization())
|
||||
.build();
|
||||
}
|
||||
|
||||
return chain.proceed(request);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
package com.readrops.api.utils
|
||||
|
||||
import com.squareup.moshi.JsonReader
|
||||
|
||||
fun JsonReader.nextNullableString(): String? =
|
||||
if (peek() != JsonReader.Token.NULL) nextString() else nextNull()
|
||||
|
||||
fun JsonReader.nextNullableInt(): Int? =
|
||||
if (peek() != JsonReader.Token.NULL) nextInt() else nextNull()
|
@ -1,47 +0,0 @@
|
||||
package com.readrops.api.utils;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Scanner;
|
||||
|
||||
public final class LibUtils {
|
||||
|
||||
public static final String RSS_DEFAULT_CONTENT_TYPE = "application/rss+xml";
|
||||
public static final String RSS_TEXT_CONTENT_TYPE = "text/xml";
|
||||
public static final String RSS_APPLICATION_CONTENT_TYPE = "application/xml";
|
||||
public static final String ATOM_CONTENT_TYPE = "application/atom+xml";
|
||||
public static final String JSON_CONTENT_TYPE = "application/json";
|
||||
public static final String HTML_CONTENT_TYPE = "text/html";
|
||||
|
||||
public static final String CONTENT_TYPE_HEADER = "content-type";
|
||||
public static final String ETAG_HEADER = "ETag";
|
||||
public static final String IF_NONE_MATCH_HEADER = "If-None-Match";
|
||||
public static final String LAST_MODIFIED_HEADER = "Last-Modified";
|
||||
public static final String IF_MODIFIED_HEADER = "If-Modified-Since";
|
||||
|
||||
public static final int HTTP_UNPROCESSABLE = 422;
|
||||
public static final int HTTP_NOT_FOUND = 404;
|
||||
public static final int HTTP_CONFLICT = 409;
|
||||
|
||||
|
||||
public static String inputStreamToString(InputStream input) {
|
||||
Scanner scanner = new Scanner(input).useDelimiter("\\A");
|
||||
return scanner.hasNext() ? scanner.next() : "";
|
||||
}
|
||||
|
||||
public static String fileToString(Uri uri, Context context) throws FileNotFoundException {
|
||||
InputStream inputStream = context.getContentResolver().openInputStream(uri);
|
||||
|
||||
return inputStreamToString(inputStream);
|
||||
}
|
||||
|
||||
public static boolean isMimeImage(@NonNull String type) {
|
||||
return type.equals("image") || type.equals("image/jpeg") || type.equals("image/jpg")
|
||||
|| type.equals("image/png");
|
||||
}
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
package com.readrops.api.utils;
|
||||
|
||||
public class ParseException extends Exception {
|
||||
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package com.readrops.api.utils;
|
||||
package com.readrops.api.utils.exceptions;
|
||||
|
||||
public class ConflictException extends Exception {
|
||||
|
@ -0,0 +1,12 @@
|
||||
package com.readrops.api.utils.exceptions;
|
||||
|
||||
public class ParseException extends Exception {
|
||||
|
||||
public ParseException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public ParseException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package com.readrops.api.utils;
|
||||
package com.readrops.api.utils.exceptions;
|
||||
|
||||
public class UnknownFormatException extends Exception {
|
||||
|
@ -0,0 +1,15 @@
|
||||
package com.readrops.api.utils.extensions
|
||||
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.squareup.moshi.JsonReader
|
||||
|
||||
fun JsonReader.nextNullableString(): String? =
|
||||
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")
|
||||
}
|
||||
|
||||
fun JsonReader.nextNullableInt(): Int? =
|
||||
if (peek() != JsonReader.Token.NULL) nextInt() else nextNull()
|
@ -0,0 +1,21 @@
|
||||
package com.readrops.api.utils.extensions
|
||||
|
||||
import com.gitlab.mvysny.konsumexml.Konsumer
|
||||
import com.gitlab.mvysny.konsumexml.Whitespace
|
||||
import com.gitlab.mvysny.konsumexml.textRecursively
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
|
||||
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
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package com.readrops.api;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.*;
|
||||
|
||||
/**
|
||||
* Example local unit test, which will execute on the development machine (host).
|
||||
*
|
||||
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||
*/
|
||||
public class ExampleUnitTest {
|
||||
@Test
|
||||
public void addition_isCorrect() {
|
||||
assertEquals(4, 2 + 2);
|
||||
}
|
||||
}
|
9
api/src/test/java/com/readrops/api/TestUtils.kt
Normal file
9
api/src/test/java/com/readrops/api/TestUtils.kt
Normal file
@ -0,0 +1,9 @@
|
||||
package com.readrops.api
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
object TestUtils {
|
||||
|
||||
fun loadResource(path: String): InputStream =
|
||||
javaClass.classLoader?.getResourceAsStream(path)!!
|
||||
}
|
@ -0,0 +1,206 @@
|
||||
package com.readrops.api.localfeed
|
||||
|
||||
import android.accounts.NetworkErrorException
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.apiModule
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.api.utils.AuthInterceptor
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.exceptions.UnknownFormatException
|
||||
import junit.framework.TestCase.*
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.KoinTestRule
|
||||
import org.koin.test.inject
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
||||
class LocalRSSDataSourceTest : KoinTest {
|
||||
|
||||
private lateinit var url: HttpUrl
|
||||
|
||||
private val mockServer: MockWebServer = MockWebServer()
|
||||
private val localRSSDataSource by inject<LocalRSSDataSource>()
|
||||
|
||||
@get:Rule
|
||||
val koinTestRule = KoinTestRule.create {
|
||||
modules(apiModule, module {
|
||||
single(override = true) {
|
||||
OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.addInterceptor(get<AuthInterceptor>())
|
||||
.build()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
mockServer.start(8080)
|
||||
url = mockServer.url("/rss")
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockServer.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successfulQueryTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/rss_feed.xml")
|
||||
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8")
|
||||
.addHeader(ApiUtils.ETAG_HEADER, "ETag-value")
|
||||
.addHeader(ApiUtils.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 = TestUtils.loadResource("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(ApiUtils.ETAG_HEADER, "ETag", ApiUtils.LAST_MODIFIED_HEADER, "Last-Modified")
|
||||
localRSSDataSource.queryRSSResource(url.toString(), headers)
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertEquals(request.headers[ApiUtils.ETAG_HEADER], "ETag")
|
||||
assertEquals(request.headers[ApiUtils.LAST_MODIFIED_HEADER], "Last-Modified")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsonFeedTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/json/json_feed.json")
|
||||
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.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 = TestUtils.loadResource("localfeed/atom/atom_feed_no_url_siteurl.xml")
|
||||
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.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 = TestUtils.loadResource("localfeed/rss1/rss1_feed_no_url_siteurl.xml")
|
||||
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.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()))
|
||||
}
|
||||
}
|
@ -0,0 +1,91 @@
|
||||
package com.readrops.api.localfeed
|
||||
|
||||
import junit.framework.TestCase.*
|
||||
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)
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isRSSTypeTest() {
|
||||
assertTrue(LocalRSSHelper.isRSSType("application/rss+xml"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isRSSTypeNullCaseTest() {
|
||||
assertFalse(LocalRSSHelper.isRSSType(null))
|
||||
}
|
||||
|
||||
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
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.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.ExpectedException
|
||||
|
||||
class XmlAdapterTest {
|
||||
|
||||
@get:Rule
|
||||
val expectedException: ExpectedException = ExpectedException.none()
|
||||
|
||||
@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)
|
||||
|
||||
expectedException.expect(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)
|
||||
|
||||
expectedException.expect(IllegalArgumentException::class.java)
|
||||
XmlAdapter.xmlItemsAdapterFactory(LocalRSSHelper.RSSType.UNKNOWN)
|
||||
}
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package com.readrops.api.localfeed.atom
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ATOMFeedAdapterTest {
|
||||
|
||||
private val adapter = ATOMFeedAdapter()
|
||||
|
||||
@Test
|
||||
fun normalCasesTest() {
|
||||
val stream = TestUtils.loadResource("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")
|
||||
}
|
||||
}
|
@ -0,0 +1,64 @@
|
||||
package com.readrops.api.localfeed.atom
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.ExpectedException
|
||||
|
||||
class ATOMItemsAdapterTest {
|
||||
|
||||
private val adapter = ATOMItemsAdapter()
|
||||
|
||||
@get:Rule
|
||||
val expectedException: ExpectedException = ExpectedException.none()
|
||||
|
||||
@Test
|
||||
fun normalCasesTest() {
|
||||
val stream = TestUtils.loadResource("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 = TestUtils.loadResource("localfeed/atom/atom_items_no_date.xml")
|
||||
|
||||
val item = adapter.fromXml(stream).first()
|
||||
assertNotNull(item.pubDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noTitleTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/atom/atom_items_no_title.xml")
|
||||
|
||||
expectedException.expect(ParseException::class.java)
|
||||
expectedException.expectMessage("Item title is required")
|
||||
|
||||
adapter.fromXml(stream)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noLinkTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/atom/atom_items_no_link.xml")
|
||||
|
||||
expectedException.expect(ParseException::class.java)
|
||||
expectedException.expectMessage("Item link is required")
|
||||
|
||||
adapter.fromXml(stream)
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,29 @@
|
||||
package com.readrops.api.localfeed.json
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.squareup.moshi.Moshi
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import okio.Buffer
|
||||
import org.junit.Test
|
||||
|
||||
class JSONFeedAdapterTest {
|
||||
|
||||
private val adapter = Moshi.Builder()
|
||||
.add(JSONFeedAdapter())
|
||||
.build()
|
||||
.adapter(Feed::class.java)
|
||||
|
||||
@Test
|
||||
fun normalCasesTest() {
|
||||
val stream = TestUtils.loadResource("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.")
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,83 @@
|
||||
package com.readrops.api.localfeed.json
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Item
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import okio.Buffer
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.ExpectedException
|
||||
|
||||
|
||||
class JSONItemsAdapterTest {
|
||||
|
||||
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))
|
||||
|
||||
@get:Rule
|
||||
val expectedException: ExpectedException = ExpectedException.none()
|
||||
|
||||
@Test
|
||||
fun normalCasesTest() {
|
||||
val stream = TestUtils.loadResource("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 = TestUtils.loadResource("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 = TestUtils.loadResource("localfeed/json/json_items_no_date.json")
|
||||
|
||||
val item = adapter.fromJson(Buffer().readFrom(stream))!!.first()
|
||||
assertNotNull(item.pubDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullTitleTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/json/json_items_no_title.json")
|
||||
|
||||
expectedException.expect(ParseException::class.java)
|
||||
expectedException.expectMessage("Item title is required")
|
||||
|
||||
adapter.fromJson(Buffer().readFrom(stream))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullLinkTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/json/json_items_no_link.json")
|
||||
|
||||
expectedException.expect(ParseException::class.java)
|
||||
expectedException.expectMessage("Item link is required")
|
||||
|
||||
adapter.fromJson(Buffer().readFrom(stream))
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,22 @@
|
||||
package com.readrops.api.localfeed.rss1
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import junit.framework.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RSS1FeedAdapterTest {
|
||||
|
||||
private val adapter = RSS1FeedAdapter()
|
||||
|
||||
@Test
|
||||
fun normalCaseTest() {
|
||||
val stream = TestUtils.loadResource("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")
|
||||
}
|
||||
}
|
@ -0,0 +1,76 @@
|
||||
package com.readrops.api.localfeed.rss1
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.ExpectedException
|
||||
|
||||
class RSS1ItemsAdapterTest {
|
||||
|
||||
private val adapter = RSS1ItemsAdapter()
|
||||
|
||||
@get:Rule
|
||||
val expectedException: ExpectedException = ExpectedException.none()
|
||||
|
||||
@Test
|
||||
fun normalCasesTest() {
|
||||
val stream = TestUtils.loadResource("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 = TestUtils.loadResource("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 = TestUtils.loadResource("localfeed/rss1/rss1_items_no_date.xml")
|
||||
|
||||
val item = adapter.fromXml(stream).first()
|
||||
assertNotNull(item.pubDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullTitleTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/rss1/rss1_items_no_title.xml")
|
||||
|
||||
expectedException.expect(ParseException::class.java)
|
||||
expectedException.expectMessage("Item title is required")
|
||||
|
||||
adapter.fromXml(stream)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullLinkTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/rss1/rss1_items_no_link.xml")
|
||||
|
||||
expectedException.expect(ParseException::class.java)
|
||||
expectedException.expectMessage("RSS1 link or about element is required")
|
||||
|
||||
adapter.fromXml(stream)
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
package com.readrops.api.localfeed.rss2
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class RSS2FeedAdapterTest {
|
||||
|
||||
|
||||
private val adapter = RSS2FeedAdapter()
|
||||
|
||||
@Test
|
||||
fun normalCasesTest() {
|
||||
val stream = TestUtils.loadResource("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 = TestUtils.loadResource("localfeed/rss2/rss_feed_special_cases.xml")
|
||||
adapter.fromXml(stream)
|
||||
}
|
||||
}
|
@ -0,0 +1,98 @@
|
||||
package com.readrops.api.localfeed.rss2
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.ExpectedException
|
||||
|
||||
class RSS2ItemsAdapterTest {
|
||||
|
||||
private val adapter = RSS2ItemsAdapter()
|
||||
|
||||
@get:Rule
|
||||
val expectedException: ExpectedException = ExpectedException.none()
|
||||
|
||||
@Test
|
||||
fun normalCasesTest() {
|
||||
val stream = TestUtils.loadResource("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 = TestUtils.loadResource("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 = TestUtils.loadResource("localfeed/rss2/rss_items_no_date.xml")
|
||||
val item = adapter.fromXml(stream).first()
|
||||
|
||||
assertNotNull(item.pubDate)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noTitleTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_no_title.xml")
|
||||
|
||||
expectedException.expect(ParseException::class.java)
|
||||
expectedException.expectMessage("Item title is required")
|
||||
|
||||
adapter.fromXml(stream)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noLinkTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_no_link.xml")
|
||||
|
||||
expectedException.expect(ParseException::class.java)
|
||||
expectedException.expectMessage("Item link is required")
|
||||
|
||||
adapter.fromXml(stream)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun enclosureTest() {
|
||||
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_enclosure.xml")
|
||||
val item = adapter.fromXml(stream).first()
|
||||
|
||||
assertEquals(item.imageLink, "https://image1.jpg")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaContentTest() {
|
||||
val stream = TestUtils.loadResource("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 = TestUtils.loadResource("localfeed/rss2/rss_items_media_group.xml")
|
||||
val item = adapter.fromXml(stream).first()
|
||||
|
||||
assertEquals(item.imageLink, "https://image1.jpg")
|
||||
}
|
||||
}
|
@ -0,0 +1,30 @@
|
||||
package com.readrops.api.services.freshrss.adapters
|
||||
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import okio.Buffer
|
||||
import org.junit.Test
|
||||
|
||||
class FreshRSSItemsIdsAdapterTest {
|
||||
|
||||
private val adapter = Moshi.Builder()
|
||||
.add(Types.newParameterizedType(List::class.java, String::class.java), FreshRSSItemsIdsAdapter())
|
||||
.build()
|
||||
.adapter<List<String>>(Types.newParameterizedType(List::class.java, String::class.java))
|
||||
|
||||
@Test
|
||||
fun validIdsTest() {
|
||||
val stream = javaClass.classLoader!!.getResourceAsStream("services/freshrss/adapters/items_starred_ids.json")
|
||||
|
||||
val ids = adapter.fromJson(Buffer().readFrom(stream))!!
|
||||
|
||||
assertEquals(ids, listOf(
|
||||
"tag:google.com,2005:reader/item/0005b2c17277b383",
|
||||
"tag:google.com,2005:reader/item/0005b2c12d328ae4",
|
||||
"tag:google.com,2005:reader/item/0005b2c0781d0737",
|
||||
"tag:google.com,2005:reader/item/0005b2bf3852c293",
|
||||
"tag:google.com,2005:reader/item/0005b2bebeed9f7f"
|
||||
))
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
package com.readrops.api.services.nextcloudnews.adapters
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import okio.Buffer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class NextNewsFeedsAdapterTest {
|
||||
|
||||
private val adapter = Moshi.Builder()
|
||||
.add(NextNewsFeedsAdapter())
|
||||
.build()
|
||||
.adapter<List<Feed>>(Types.newParameterizedType(List::class.java, Feed::class.java))
|
||||
|
||||
@Test
|
||||
fun validFeedsTest() {
|
||||
val stream = TestUtils.loadResource("services/nextcloudnews/feeds.json")
|
||||
|
||||
val feeds = adapter.fromJson(Buffer().readFrom(stream))!!
|
||||
val feed1 = feeds[0]
|
||||
|
||||
assertEquals(feed1.name, "Krebs on Security")
|
||||
assertEquals(feed1.url, "https://krebsonsecurity.com/feed/")
|
||||
assertEquals(feed1.siteUrl, "https://krebsonsecurity.com/")
|
||||
assertEquals(feed1.remoteId, "3")
|
||||
assertNull(feed1.remoteFolderId)
|
||||
assertEquals(feed1.iconUrl, "https://krebsonsecurity.com/favicon.ico")
|
||||
|
||||
val feed2 = feeds[1]
|
||||
assertNull(feed2.remoteFolderId)
|
||||
|
||||
val feed3 = feeds[2]
|
||||
assertEquals(feed3.name, "krebsonsecurity.com")
|
||||
assertEquals(feed3.remoteFolderId, "5")
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
package com.readrops.api.services.nextcloudnews.adapters
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class NextNewsUserAdapterTest {
|
||||
|
||||
private val adapter = NextNewsUserAdapter()
|
||||
|
||||
@Test
|
||||
fun validXmlTest() {
|
||||
val stream = TestUtils.loadResource("services/nextcloudnews/user.xml")
|
||||
|
||||
assertEquals(adapter.fromXml(stream), "Shinokuni")
|
||||
}
|
||||
}
|
25
api/src/test/java/com/readrops/api/utils/ApiUtilsTest.kt
Normal file
25
api/src/test/java/com/readrops/api/utils/ApiUtilsTest.kt
Normal file
@ -0,0 +1,25 @@
|
||||
package com.readrops.api.utils
|
||||
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ApiUtilsTest {
|
||||
|
||||
@Test
|
||||
fun contentTypeWithCharsetTest() {
|
||||
assertEquals(ApiUtils.parseContentType("application/rss+xml; charset=UTF-8"),
|
||||
"application/rss+xml")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contentTypeWithoutCharsetText() {
|
||||
assertEquals(ApiUtils.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", ApiUtils.cleanText(text))
|
||||
}
|
||||
}
|
@ -0,0 +1,53 @@
|
||||
package com.readrops.api.utils
|
||||
|
||||
import com.readrops.api.services.freshrss.FreshRSSCredentials
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class AuthInterceptorTest {
|
||||
|
||||
private val interceptor = AuthInterceptor()
|
||||
private val mockServer = MockWebServer()
|
||||
private lateinit var okHttpClient: OkHttpClient
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
okHttpClient = OkHttpClient.Builder().addInterceptor(interceptor).build()
|
||||
mockServer.start(8080)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockServer.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun credentialsUrlTest() {
|
||||
mockServer.enqueue(MockResponse())
|
||||
interceptor.credentials = FreshRSSCredentials("token", "http://localhost:8080/rss")
|
||||
|
||||
okHttpClient.newCall(Request.Builder().url(mockServer.url("/url")).build()).execute()
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertEquals(request.headers["Authorization"], "GoogleLogin auth=token")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nullCredentialsTest() {
|
||||
mockServer.enqueue(MockResponse())
|
||||
interceptor.credentials = null
|
||||
|
||||
okHttpClient.newCall(Request.Builder().url(mockServer.url("/url")).build()).execute()
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertEquals(request.requestUrl.toString(), "http://localhost:8080/url")
|
||||
assertNull(request.headers["Authorization"])
|
||||
}
|
||||
}
|
@ -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")));
|
||||
}
|
||||
}
|
@ -0,0 +1,88 @@
|
||||
package com.readrops.api.utils
|
||||
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||
import com.readrops.api.utils.extensions.nextNullableString
|
||||
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.nextNonEmptyString(), "value")
|
||||
reader.endObject()
|
||||
}
|
||||
|
||||
@Test(expected = ParseException::class)
|
||||
fun nextNonEmptyStringEmptyCaseTest() {
|
||||
val reader = JsonReader.of(Buffer().readFrom("""
|
||||
{
|
||||
"field": ""
|
||||
}
|
||||
""".trimIndent().byteInputStream()))
|
||||
|
||||
reader.beginObject()
|
||||
reader.nextName()
|
||||
|
||||
reader.nextNonEmptyString()
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,90 @@
|
||||
package com.readrops.api.utils
|
||||
|
||||
import com.gitlab.mvysny.konsumexml.KonsumerException
|
||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.nullableText
|
||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
9
api/src/test/resources/localfeed/atom/atom_feed.xml
Normal file
9
api/src/test/resources/localfeed/atom/atom_feed.xml
Normal 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>
|
@ -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>
|
71
api/src/test/resources/localfeed/atom/atom_items.xml
Normal file
71
api/src/test/resources/localfeed/atom/atom_items.xml
Normal 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&u=c56b216e8d128d0ec217062feeace9faca4dc893&v=4"/>
|
||||
<author>
|
||||
<name>Shinokuni</name>
|
||||
<uri>https://github.com/Shinokuni</uri>
|
||||
</author>
|
||||
<summary>Summary</summary>
|
||||
<content type="html">
|
||||
<pre style='white-space:pre-wrap;width:81ex'>Add an option to open item url in custom tab</pre>
|
||||
</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&u=c56b216e8d128d0ec217062feeace9faca4dc893&v=4"/>
|
||||
<author>
|
||||
<name>Shinokuni</name>
|
||||
<uri>https://github.com/Shinokuni</uri>
|
||||
</author>
|
||||
<content type="html">
|
||||
<pre style='white-space:pre-wrap;width:81ex'>Use gradle parallel builds</pre>
|
||||
</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&u=c56b216e8d128d0ec217062feeace9faca4dc893&v=4"/>
|
||||
<author>
|
||||
<name>Shinokuni</name>
|
||||
<uri>https://github.com/Shinokuni</uri>
|
||||
</author>
|
||||
<content type="html">
|
||||
<pre style='white-space:pre-wrap;width:81ex'>Use clear text mode for the feed url text input in AddFeedActivity</pre>
|
||||
</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&u=c56b216e8d128d0ec217062feeace9faca4dc893&v=4"/>
|
||||
<author>
|
||||
<name>Shinokuni</name>
|
||||
<uri>https://github.com/Shinokuni</uri>
|
||||
</author>
|
||||
<content type="html">
|
||||
<pre style='white-space:pre-wrap;width:81ex'>Use project level okhttp client with glide</pre>
|
||||
</content>
|
||||
</entry>
|
||||
</feed>
|
22
api/src/test/resources/localfeed/atom/atom_items_no_date.xml
Normal file
22
api/src/test/resources/localfeed/atom/atom_items_no_date.xml
Normal 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&u=c56b216e8d128d0ec217062feeace9faca4dc893&v=4"/>
|
||||
<author>
|
||||
<name>Shinokuni</name>
|
||||
<uri>https://github.com/Shinokuni</uri>
|
||||
</author>
|
||||
<summary>Summary</summary>
|
||||
<content type="html">
|
||||
<pre style='white-space:pre-wrap;width:81ex'>Add an option to open item url in custom tab</pre>
|
||||
</content>
|
||||
</entry>
|
||||
</feed>
|
22
api/src/test/resources/localfeed/atom/atom_items_no_link.xml
Normal file
22
api/src/test/resources/localfeed/atom/atom_items_no_link.xml
Normal 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&u=c56b216e8d128d0ec217062feeace9faca4dc893&v=4"/>
|
||||
<author>
|
||||
<name>Shinokuni</name>
|
||||
<uri>https://github.com/Shinokuni</uri>
|
||||
</author>
|
||||
<summary>Summary</summary>
|
||||
<content type="html">
|
||||
<pre style='white-space:pre-wrap;width:81ex'>Add an option to open item url in custom tab</pre>
|
||||
</content>
|
||||
</entry>
|
||||
</feed>
|
@ -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&u=c56b216e8d128d0ec217062feeace9faca4dc893&v=4"/>
|
||||
<author>
|
||||
<name>Shinokuni</name>
|
||||
<uri>https://github.com/Shinokuni</uri>
|
||||
</author>
|
||||
<summary>Summary</summary>
|
||||
<content type="html">
|
||||
<pre style='white-space:pre-wrap;width:81ex'>Add an option to open item url in custom tab</pre>
|
||||
</content>
|
||||
</entry>
|
||||
</feed>
|
86
api/src/test/resources/localfeed/json/json_feed.json
Normal file
86
api/src/test/resources/localfeed/json/json_feed.json
Normal 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'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'm also working on some 10.13 goodies for Acorn 6 folks later this year. I can't wait to share that with you, but you'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'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's going to start getting brighter for you though).</p>\n<p>Today I'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'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'll be a focus- as well as Dark Mode for Acorn and one other major thing I've got planned. Retrobatch will probably also get the Dark Mode treatment, but not until I've done it for Acorn first.</p>\n<p>So it's going to be a busy summer, but I'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'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'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'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 "Only scale smaller" for the Scale node.</p>\n<p>And an interesting idea that I've had folks ask about a number of times- it'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've got lots of ideas for future releases, but if you'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'm calling the JavaScript node a "preview". It works very well, but I'm not 100% sold on the API that I'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'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't already upgraded from previous versions of Acorn, now is a good time to do so.</p>\n<p>We've also packed a bunch of little changes, bug fixes, and compatibility with Mojave in there. And of course, there'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'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'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've also added options to the brush palette for adjusting flow, softness and blending. In addition to all this, there's a bunch of new brushes under the "Basic Round" category which are designed for the new brush engine.</p>\n<p><strong>Other Stuff</strong>. There'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'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'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 "Round Corner", "Image Grid", and "Limit". We'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're <a href=\"https://flyingmeat.com/retrobatch/releasenotes.html\">always listening for feedback</a> and feature requests. And don'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'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're <a href=\"https://flyingmeat.com/store/\">discounting Acorn by 50% for a limited time</a>. So if you haven'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'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's a couple of interesting new features in this update I'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'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'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'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'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 "invert colors" 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'd have to add an Invert Colors node (or filter for Acorn), then the Mask to Alpha, and then Invert Colors again. Now it's just a checkbox in Mask to Alpha, which is super easy. I'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'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're not already familiar with the shape processor, it'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'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'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's just two processors stacked together.</p>\n<p>Have you made something interesting with the Shape Processor? I'd love to see it either via Twitter (I'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'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'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"
|
||||
}
|
||||
]
|
||||
}
|
@ -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'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'm also working on some 10.13 goodies for Acorn 6 folks later this year. I can't wait to share that with you, but you'll have to wait just a little bit.</p>\n",
|
||||
"url": "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html"
|
||||
}
|
||||
]
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user