Compare commits

..

No commits in common. "develop" and "compose-migration" have entirely different histories.

566 changed files with 17772 additions and 22524 deletions

1
.gitattributes vendored
View File

@ -1 +0,0 @@
*.html linguist-vendored

1
.github/FUNDING.yml vendored
View File

@ -1 +0,0 @@
custom: ["https://paypal.me/readropsapp"]

View File

@ -1,32 +0,0 @@
---
name: Bug report
about: Create a report to help us fix a bug
title: "[Bug] "
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Environment information (please complete the following information):**
- Account type: [e.g. FreshRSS, Nextcloud News]
- App version: [e.g. 2.0]
- Android version: [e.g. Android 13, 14]
- Device type: [e.g. One Plus 12, Samsung Galaxy S23]
- Store: [e.g F-Droid, Play Store, standalone apk]
- [ ] Stacktrace collected from crash screen
**Additional context**
Add any other context about the problem here.

View File

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature]"
labels: ''
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@ -11,28 +11,22 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- name: set up JDK 1.17
- name: set up JDK 1.11
uses: actions/setup-java@v3
with:
distribution: 'temurin'
java-version: '17'
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
java-version: '11'
- name: Android Emulator Runner
uses: ReactiveCircus/android-emulator-runner@v2.33.0
uses: ReactiveCircus/android-emulator-runner@v2.28.0
with:
api-level: 29
script: ./gradlew clean build connectedCheck jacocoFullReport
- uses: codecov/codecov-action@v4
- uses: codecov/codecov-action@v2.1.0
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./build/reports/jacoco/jacocoFullReport/jacocoFullReport.xml
files: ./build/reports/jacoco/jacocoFullReport.xml
fail_ci_if_error: true
verbose: true

2
.gitignore vendored
View File

@ -135,5 +135,3 @@ Gemfile
mapping/
**/*.exec
.kotlin/

View File

@ -1,94 +1,8 @@
# v2.0.3
- Fix Fever API compatibility with TinyTiny RSS and yarr, should also fix other providers (#228 + #229)
- Fix Nextcloud News item duplicates when syncing which would made the app unusable
- Fix Nextcloud News item parsing: items with no title will be ignored
# v2.0.2
- Fix crash when opening app from a notification (#223)
- Fix Fever API synchronization error (#228)
# v2.0.1
- Make Timeline tab filters persistent (#138)
- Change Timeline tab order field default value (#202)
- Fix crash when adding a Fever API account (#200)
- Be less strict with feed and folder names (#206)
# v2.0
- Restore swipe to mark as read (#188)
- Restore Ordering by article id in Timeline tab
- Improve OPML file picker filtering (#195)
- Translation updates
- See previous beta versions to get full changelog since v1.3
# v2.0-beta02
- Fix migration issues from v1.3 and older (especially for F-Droid builds)
- Make Preferences screen scrollable (#190)
- Fix wrong translation in RadioButtonPreferenceDialog (#185)
- Translation updates
# v2.0-beta01
## General
- 🆕 design:
- 🆕 Material3: Readrops implements last material design system version
- 🆕 Bottom bar navigation: you can now navigate to feeds and account management way more easily, with 4 tabs in total:
- Timeline
- Feeds
- Account
- More
- Timeline tab:
- 🆕 Article size: you can now choose among three article sizes: compact, regular and large
- 🆕 You can now show only articles less than 24h old
- 🆕 Mark all articles as read FAB: the floating action button now lets you mark all articles read, taking into account the current filter, replacing opening new feed activity action
- 🆕 Mark articles read on scroll: an option is now available to mark items read on scroll
- 🆕 Drawer: hide folders and feeds without unread articles
- 🆕 Local account: sync now respects the current filter and will only synchronize affected feeds
- Feeds Tab:
- 🆕 Feeds and folder management have been merged into a single screen
- Account Tab:
- 🆕 Add, manage and remove any account from Account Tab
- More Tab:
- 🆕 This new screen gathers some app infos, parameters, open source libraries and a donation dialog
- Articles screen:
- The global UI has been improved with a new title layout
- 🆕 Action bottom bar: you now have access to a collapsable bottom bar containing the following actions:
- Mark as read/non read
- Add to favorites/remove from favorites
- Share
- Open in external navigator/navigator view
- 🆕 A new font, Inter is used for the article content
- 🆕 Some html tags look have been improved:
- blockquote
- figure/figcaption
- iframe
- table
- "Open in" option has been reduced to two options: navigator view and external navigator
- 🆕 FEVER API implementation, should work with any provider which supports it
- Migrate to Nextcloud News API 1.3
- 🆕 Follow system theme option (default)
- 🆕 Option to disable battery optimization for background synchronization
- Add support for new Android versions until Android 14 (API 34)
## Technical
- The UI has been entirely rewritten in Kotlin using Jetpack Compose, moving from old traditional view system
- All other Java parts have also been rewritten in Kotlin, including API implementations, repositories, etc
- RXJava was replaced by Kotlin coroutines and flows
- Migrate to Gradle Kotlin DSL
- Migrate dependencies to Version Catalog
- 🆕 Support user certificates
# v1.3.1
- FreshRSS : Fix items being fav unintentionally
- FreshRSS : Fix 401 error when synchronising for the second time
# v1.3.0
- New local RSS parser, much reliable
- New external navigator view for items (Custom tabs)
- FreshRSS and Nextcloud News favorites
@ -150,13 +64,15 @@ Fix a crash related to Proguard Rules.
# v1.0.2
- Add swipe background to main list items
- Add preference to parse a fixed number of items when adding a local feed
- Change feed/folders way to interact
- Minor bug fixes and improvements
- Add swipe background to main list items
- Add preference to parse a fixed number of items when adding a local feed
- Change feed/folders way to interact
- Minor bug fixes and improvements
# v1.0.1
# v1.0 Initial release
- Local RSS parsing

View File

@ -1,45 +1,40 @@
<p align="center">
<img src="fastlane/metadata/android/en-US/images/icon.png" width=180>
<img src="fastlane/metadata/android/en-US/images/icon.png" width=180>
</p>
<h1 align="center"><b>Readrops</b></h1>
<p align="center">
<a href="https://github.com/readrops/Readrops/actions"><img src="https://github.com/readrops/Readrops/actions/workflows/android.yml/badge.svg?branch=develop"></a>
<a href="https://github.com/readrops/Readrops/actions"><img src="https://github.com/readrops/Readrops/workflows/Android%20CI/badge.svg?branch=develop"></a>
<a href="https://codecov.io/gh/readrops/Readrops"><img src="https://codecov.io/gh/readrops/Readrops/branch/develop/graph/badge.svg?token=229PNPQPMM"></a>
<a href="https://hosted.weblate.org/engage/readrops/"><img src="https://hosted.weblate.org/widgets/readrops/-/strings/svg-badge.svg"/></a>
<h4 align="center">Readrops is a multi-services RSS client for Android. Its name is composed of "Read" and "drops", where drops are articles in an ocean of news.</h4>
<h4 align="center">Readrops is a multi-services RSS client for Android. Its name is composed of "Read" and "drops", where drops are information drops in an ocean of news.</h4>
<p align="center">
<a href="https://play.google.com/store/apps/details?id=com.readrops.app"><img src="images/google-play-badge.png" width=250></a>
<a href="https://f-droid.org/en/packages/com.readrops.app/"><img src="images/fdroid-badge.png" width=250></a>
<a href="https://play.google.com/store/apps/details?id=com.readrops.app"><img src="images/google-play-badge.png" width=250></a>
<a href="https://f-droid.org/en/packages/com.readrops.app/"><img src="images/fdroid-badge.png" width=250></a>
</p>
</p>
# Features
- Local RSS parsing:
- RSS2
- RSS1
- ATOM
- JSONFeed
- External services:
- FreshRSS
- Nextcloud News
- Fever API
- Multi-account
- Feeds and folders management (create, update and delete feeds/folders if supported by the service API)
- OPML import/export
- Local RSS parsing : support for RSS 2, RSS 1, ATOM and JSONFeed
- Nextcloud news support
- FreshRSS support
- Multiple accounts
- Feeds and folders management (create, update and delete feeds/folders if your service API supports it)
- Background synchronisation
- Notifications
# Screenshots
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_1.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_2.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_3.jpg" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_1.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_2.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_3.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_4.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_5.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_6.jpg" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_4.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_5.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_6.png" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_7.jpg" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_8.jpg" width=250>
<img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_7.png" width=250> <img src="fastlane/metadata/android/en-US/images/phoneScreenshots/Screenshot_8.png" width=250>
# Licence

80
api/build.gradle Normal file
View File

@ -0,0 +1,80 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
android {
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/androidTest/assets".toString())
}
buildTypes {
release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled false
testCoverageEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
lint {
abortOnError false
}
namespace 'com.readrops.api'
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':db')
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'
testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:1.0'
implementation 'org.redundent:kotlin-xml-builder:1.7.3'
api 'com.squareup.okhttp3:okhttp:4.9.1'
implementation('com.squareup.retrofit2:retrofit:2.9.0') {
exclude group: 'com.squareup.okhttp3', module: 'okhttp3'
}
implementation('com.squareup.retrofit2:converter-moshi:2.9.0') {
exclude group: 'com.squareup.moshi', module: 'moshi'
}
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
implementation 'com.squareup.moshi:moshi:1.12.0'
api 'io.reactivex.rxjava2:rxandroid:2.1.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'
}

View File

@ -1,63 +0,0 @@
plugins {
id("com.android.library")
kotlin("android")
}
android {
namespace = "com.readrops.api"
buildTypes {
debug {
enableUnitTestCoverage = true
}
create("beta") {
initWith(getByName("release"))
signingConfig = signingConfigs.getByName("debug")
}
}
sourceSets {
getByName("androidTest") {
assets.srcDirs("$projectDir/androidTest/assets")
}
}
kotlinOptions {
freeCompilerArgs = listOf("-Xstring-concat=inline")
}
lint {
abortOnError = false
}
}
dependencies {
implementation(project(":db"))
coreLibraryDesugaring(libs.jdk.desugar)
testImplementation(libs.junit4)
implementation(libs.coroutines.core)
testImplementation(libs.coroutines.test)
implementation(platform(libs.koin.bom))
implementation(libs.bundles.koin)
testImplementation(libs.bundles.kointest)
implementation(libs.konsumexml)
implementation(libs.kotlinxmlbuilder)
implementation(libs.okhttp)
testImplementation(libs.okhttp.mockserver)
implementation(libs.bundles.retrofit) {
exclude("com.squareup.okhttp3", "okhttp3")
exclude("com.squareup.moshi", "moshi")
}
implementation(libs.moshi)
implementation(libs.jsoup)
}

View File

@ -2,27 +2,14 @@ package com.readrops.api
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.Credentials
import com.readrops.api.services.fever.FeverDataSource
import com.readrops.api.services.fever.FeverService
import com.readrops.api.services.fever.adapters.FeverAPIAdapter
import com.readrops.api.services.fever.adapters.FeverFaviconsAdapter
import com.readrops.api.services.fever.adapters.FeverFeeds
import com.readrops.api.services.fever.adapters.FeverFeedsAdapter
import com.readrops.api.services.fever.adapters.FeverFoldersAdapter
import com.readrops.api.services.fever.adapters.FeverItemsAdapter
import com.readrops.api.services.fever.adapters.FeverItemsIdsAdapter
import com.readrops.api.services.greader.GReaderDataSource
import com.readrops.api.services.greader.GReaderService
import com.readrops.api.services.greader.adapters.FreshRSSUserInfoAdapter
import com.readrops.api.services.greader.adapters.GReaderFeedsAdapter
import com.readrops.api.services.greader.adapters.GReaderFoldersAdapter
import com.readrops.api.services.greader.adapters.GReaderItemsAdapter
import com.readrops.api.services.greader.adapters.GReaderItemsIdsAdapter
import com.readrops.api.services.nextcloudnews.NextcloudNewsDataSource
import com.readrops.api.services.nextcloudnews.NextcloudNewsService
import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsFeedsAdapter
import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsFoldersAdapter
import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsItemsAdapter
import com.readrops.api.services.freshrss.FreshRSSDataSource
import com.readrops.api.services.freshrss.FreshRSSService
import com.readrops.api.services.freshrss.adapters.*
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.api.utils.ErrorInterceptor
import com.readrops.db.entities.Item
@ -32,6 +19,7 @@ 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
@ -39,12 +27,12 @@ val apiModule = module {
single {
OkHttpClient.Builder()
.callTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.MINUTES)
.addInterceptor(get<AuthInterceptor>())
.addInterceptor(get<ErrorInterceptor>())
//.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
.build()
.callTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.HOURS)
.addInterceptor(get<AuthInterceptor>())
.addInterceptor(get<ErrorInterceptor>())
//.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
.build()
}
single { AuthInterceptor() }
@ -53,77 +41,53 @@ val apiModule = module {
single { LocalRSSDataSource(get()) }
//region greader/freshrss
//region freshrss
factory { params -> GReaderDataSource(get(parameters = { params })) }
factory { params -> FreshRSSDataSource(get(parameters = { params })) }
factory { (credentials: Credentials) ->
Retrofit.Builder()
.baseUrl(credentials.url)
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("greaderMoshi"))))
.build()
.create(GReaderService::class.java)
.baseUrl(credentials.url)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
.build()
.create(FreshRSSService::class.java)
}
single(named("greaderMoshi")) {
single(named("freshrssMoshi")) {
Moshi.Builder()
.add(Types.newParameterizedType(List::class.java, Item::class.java), GReaderItemsAdapter())
.add(Types.newParameterizedType(List::class.java, String::class.java), GReaderItemsIdsAdapter())
.add(GReaderFeedsAdapter())
.add(GReaderFoldersAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), FreshRSSItemsAdapter())
.add(Types.newParameterizedType(List::class.java, String::class.java), FreshRSSItemsIdsAdapter())
.add(FreshRSSFeedsAdapter())
.add(FreshRSSFoldersAdapter())
.add(FreshRSSUserInfoAdapter())
.build()
}
//endregion greader/freshrss
//endregion freshrss
//region nextcloud news
factory { params -> NextcloudNewsDataSource(get(parameters = { params })) }
factory { params -> NextNewsDataSource(get(parameters = { params })) }
factory { (credentials: Credentials) ->
Retrofit.Builder()
.baseUrl(credentials.url)
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi"))))
.build()
.create(NextcloudNewsService::class.java)
.create(NextNewsService::class.java)
}
single(named("nextcloudNewsMoshi")) {
Moshi.Builder()
.add(NextcloudNewsFeedsAdapter())
.add(NextcloudNewsFoldersAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextcloudNewsItemsAdapter())
.add(NextNewsFeedsAdapter())
.add(NextNewsFoldersAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter())
.build()
}
//endregion nextcloud news
//region Fever
factory { params -> FeverDataSource(get(parameters = { params })) }
factory { (credentials: Credentials) ->
Retrofit.Builder()
.baseUrl(credentials.url)
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("feverMoshi"))))
.build()
.create(FeverService::class.java)
}
single(named("feverMoshi")) {
Moshi.Builder()
.add(FeverFoldersAdapter())
.add(FeverFeeds::class.java, FeverFeedsAdapter())
.add(FeverItemsAdapter())
.add(FeverFaviconsAdapter())
.add(Boolean::class.java, FeverAPIAdapter())
.add(FeverItemsIdsAdapter())
.build()
}
//endregion Fever
}

View File

@ -1,5 +1,6 @@
package com.readrops.api.localfeed
import android.accounts.NetworkErrorException
import androidx.annotation.WorkerThread
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.konsumeXml
@ -21,6 +22,7 @@ import okio.Buffer
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.io.IOException
import java.lang.Exception
import java.net.HttpURLConnection
class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
@ -73,8 +75,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
val rootKonsumer = nextElement(LocalRSSHelper.RSS_ROOT_NAMES)
rootKonsumer?.let { type = LocalRSSHelper.guessRSSType(rootKonsumer) }
} catch (e: Exception) {
close()
return false
throw UnknownFormatException(e.message)
}
}

View File

@ -3,6 +3,7 @@ package com.readrops.api.localfeed
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.Names
import com.readrops.api.utils.extensions.checkRoot
import java.io.InputStream
object LocalRSSHelper {
@ -25,11 +26,12 @@ object LocalRSSHelper {
RSS_1_CONTENT_TYPE -> RSSType.RSS_1
RSS_2_CONTENT_TYPE -> RSSType.RSS_2
ATOM_CONTENT_TYPE -> RSSType.ATOM
JSONFEED_CONTENT_TYPE -> RSSType.JSONFEED
JSON_CONTENT_TYPE, JSONFEED_CONTENT_TYPE -> RSSType.JSONFEED
else -> RSSType.UNKNOWN
}
}
@JvmStatic
fun isRSSType(type: String?): Boolean =
if (type != null) getRSSType(type) != RSSType.UNKNOWN else false

View File

@ -1,41 +0,0 @@
package com.readrops.api.localfeed
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.readrops.api.utils.extensions.nullableTextRecursively
import com.readrops.db.entities.Item
object RSSMedia {
fun parseMediaContent(konsumer: Konsumer, item: Item) = with(konsumer) {
val url = attributes.getValueOrNull("url")
if (url != null && isUrlImage(url) && item.imageLink == null) {
item.imageLink = url
}
konsumer.skipContents() // ignore media content sub elements
}
fun parseMediaGroup(konsumer: Konsumer, item: Item) = with(konsumer) {
allChildrenAutoIgnore(Names.of("content", "thumbnail", "description")) {
when (tagName) {
"media:content" -> parseMediaContent(this, item)
"media:thumbnail"-> parseMediaContent(this, item)
"media:description" -> {
// Youtube case, might be useful for others
val description = nullableTextRecursively()
if (item.text == null) {
item.content = description
}
}
else -> skipContents()
}
}
}
private fun isUrlImage(url: String): Boolean = with(url) {
return endsWith(".jpg") || endsWith(".jpeg") || endsWith(".png")
}
}

View File

@ -28,9 +28,7 @@ class ATOMFeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
"title" -> name = nonNullText()
"link" -> parseLink(this@allChildrenAutoIgnore, feed)
"subtitle" -> description = nullableText()
"logo" -> imageUrl = nullableText()
"entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
else -> skipContents()
}
}
}
@ -53,6 +51,6 @@ class ATOMFeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
}
companion object {
val names = Names.of("title", "link", "subtitle", "logo", "entry")
val names = Names.of("title", "link", "subtitle", "entry")
}
}

View File

@ -3,15 +3,14 @@ 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.readrops.api.localfeed.RSSMedia
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 com.readrops.db.util.DateUtils
import java.time.LocalDateTime
import org.joda.time.LocalDateTime
class ATOMItemAdapter : XmlAdapter<Item> {
@ -23,19 +22,12 @@ class ATOMItemAdapter : XmlAdapter<Item> {
konsumer.allChildrenAutoIgnore(names) {
when (tagName) {
"title" -> title = nonNullText()
"id" -> remoteId = nullableText()
"published" -> pubDate = DateUtils.parse(nullableText())
"updated" -> {
val updated = nullableText()
if (pubDate == null) {
pubDate = DateUtils.parse(updated)
}
}
"id" -> guid = nullableText()
"updated" -> pubDate = DateUtils.parse(nullableText())
"link" -> parseLink(this, this@apply)
"author" -> allChildrenAutoIgnore("name") { author = nullableText() }
"summary" -> description = nullableTextRecursively()
"content" -> content = nullableTextRecursively()
"media:group" -> RSSMedia.parseMediaGroup(this, item)
else -> skipContents()
}
}
@ -43,7 +35,7 @@ class ATOMItemAdapter : XmlAdapter<Item> {
validateItem(item)
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
if (item.remoteId == null) item.remoteId = item.link
if (item.guid == null) item.guid = item.link
item
} catch (e: Exception) {
@ -65,7 +57,6 @@ class ATOMItemAdapter : XmlAdapter<Item> {
}
companion object {
val names = Names.of("title", "id", "updated", "link", "author", "summary",
"content", "group", "published")
val names = Names.of("title", "id", "updated", "link", "author", "summary", "content")
}
}

View File

@ -5,9 +5,7 @@ import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Item
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.*
class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() {
@ -29,9 +27,8 @@ class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() {
0 -> name = reader.nextNonEmptyString()
1 -> siteUrl = reader.nextNullableString()
2 -> url = reader.nextNullableString()
3 -> imageUrl = reader.nextNullableString()
4 -> description = reader.nextNullableString()
5 -> items += itemAdapter.fromJson(reader)
3 -> description = reader.nextNullableString()
4 -> items += itemAdapter.fromJson(reader)
else -> reader.skipValue()
}
}
@ -45,6 +42,6 @@ class JSONFeedAdapter : JsonAdapter<Pair<Feed, List<Item>>>() {
companion object {
val names: JsonReader.Options = JsonReader.Options.of("title", "home_page_url",
"feed_url", "icon", "description", "items")
"feed_url", "description", "items")
}
}

View File

@ -1,15 +1,15 @@
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.readrops.db.util.DateUtils
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import java.time.LocalDateTime
import org.joda.time.LocalDateTime
class JSONItemsAdapter : JsonAdapter<List<Item>>() {
@ -33,7 +33,7 @@ class JSONItemsAdapter : JsonAdapter<List<Item>>() {
while (hasNext()) {
with(item) {
when (selectName(names)) {
0 -> remoteId = nextNonEmptyString()
0 -> guid = nextNonEmptyString()
1 -> link = nextNonEmptyString()
2 -> title = nextNonEmptyString()
3 -> contentHtml = nextNullableString()

View File

@ -26,7 +26,6 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
when (tagName) {
"channel" -> parseChannel(this, feed)
"item" -> items += itemAdapter.fromXml(this)
else -> skipContents()
}
}
}
@ -40,10 +39,8 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
}
private fun parseChannel(konsumer: Konsumer, feed: Feed) = with(konsumer) {
feed.url = attributes.getValueOrNull(
localName = "about",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
)
feed.url = attributes.getValueOrNull("about",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#")
allChildrenAutoIgnore(names) {
with(feed) {
@ -51,16 +48,12 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
"title" -> name = nonNullText()
"link" -> siteUrl = nonNullText()
"description" -> description = nullableText()
"image" -> imageUrl = attributes.getValueOrNull(
localName = "resource",
namespace = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"
)
}
}
}
}
companion object {
val names = Names.of("title", "link", "description", "image")
val names = Names.of("title", "link", "description")
}
}

View File

@ -5,13 +5,13 @@ import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
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 com.readrops.db.util.DateUtils
import java.time.LocalDateTime
import org.joda.time.LocalDateTime
class RSS1ItemAdapter : XmlAdapter<Item> {
@ -40,7 +40,7 @@ class RSS1ItemAdapter : XmlAdapter<Item> {
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.remoteId = item.link
item.guid = item.link
if (authors.filterNotNull().isNotEmpty()) item.author = authors.filterNotNull()
.joinToString(limit = AUTHORS_MAX)

View File

@ -35,7 +35,6 @@ class RSS2FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
url = attributes.getValueOrNull("href")
}
"item" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
"image" -> imageUrl = parseImage(this@allChildrenAutoIgnore)
else -> skipContents()
}
}
@ -50,20 +49,7 @@ class RSS2FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
}
}
private fun parseImage(konsumer: Konsumer): String? = with(konsumer) {
var url: String? = null
allChildrenAutoIgnore(Names.of("url")) {
when (tagName) {
"url" -> url = nullableText()
else -> skipContents()
}
}
url
}
companion object {
val names = Names.of("title", "description", "link", "item", "image")
val names = Names.of("title", "description", "link", "item")
}
}

View File

@ -1,20 +1,15 @@
package com.readrops.api.localfeed.rss2
import com.gitlab.mvysny.konsumexml.Konsumer
import com.gitlab.mvysny.konsumexml.KonsumerException
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
import com.readrops.api.localfeed.RSSMedia
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.ApiUtils
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 com.readrops.db.util.DateUtils
import java.time.LocalDateTime
import org.joda.time.LocalDateTime
class RSS2ItemAdapter : XmlAdapter<Item> {
@ -22,6 +17,7 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
val item = Item()
return try {
//konsumer.checkCurrent("item")
val creators = arrayListOf<String?>()
item.apply {
@ -33,12 +29,12 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
"dc:creator" -> creators += nullableText()
"pubDate" -> pubDate = DateUtils.parse(nullableText())
"dc:date" -> pubDate = DateUtils.parse(nullableText())
"guid" -> remoteId = nullableText()
"guid" -> guid = nullableText()
"description" -> description = nullableTextRecursively()
"content:encoded" -> content = nullableTextRecursively()
"enclosure" -> RSSMedia.parseMediaContent(this, item = this@apply)
"media:content" -> RSSMedia.parseMediaContent(this, item = this@apply)
"media:group" -> RSSMedia.parseMediaGroup(this, item = this@apply)
"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
}
}
@ -51,11 +47,41 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
}
}
private fun parseEnclosure(konsumer: Konsumer, item: Item) = with(konsumer) {
if (attributes.getValueOrNull("type") != null
&& ApiUtils.isMimeImage(attributes["type"]) && item.imageLink == null)
item.imageLink = attributes.getValueOrNull("url")
}
private fun isMediumImage(konsumer: Konsumer) = with(konsumer) {
attributes.getValueOrNull("medium") != null && ApiUtils.isMimeImage(attributes["medium"])
}
private fun isTypeImage(konsumer: Konsumer) = with(konsumer) {
attributes.getValueOrNull("type") != null && ApiUtils.isMimeImage(attributes["type"])
}
private fun parseMediaContent(konsumer: Konsumer, item: Item) = with(konsumer) {
if ((isMediumImage(konsumer) || isTypeImage(konsumer)) && item.imageLink == null)
item.imageLink = konsumer.attributes.getValueOrNull("url")
konsumer.skipContents() // ignore media content sub elements
}
private fun parseMediaGroup(konsumer: Konsumer, item: Item) = with(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 (remoteId == null) remoteId = link
if (guid == null) guid = link
if (author == null && creators.filterNotNull().isNotEmpty())
author = creators.filterNotNull().joinToString(limit = AUTHORS_MAX)
}

View File

@ -1,31 +1,33 @@
package com.readrops.api.opml
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import io.reactivex.Completable
import io.reactivex.Single
import org.redundent.kotlin.xml.xml
import java.io.InputStream
import java.io.OutputStream
object OPMLParser {
suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> = withContext(Dispatchers.IO) {
try {
val adapter = OPMLAdapter()
val opml = adapter.fromXml(stream.konsumeXml())
@JvmStatic
fun read(stream: InputStream): Single<Map<Folder?, List<Feed>>> {
return Single.create { emitter ->
try {
val adapter = OPMLAdapter()
val opml = adapter.fromXml(stream.konsumeXml())
stream.close()
opml
} catch (e: Exception) {
throw ParseException(e.message)
emitter.onSuccess(opml)
} catch (e: Exception) {
emitter.onError(e)
}
}
}
suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) =
withContext(Dispatchers.IO) {
@JvmStatic
fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream): Completable {
return Completable.create { emitter ->
val opml = xml("opml") {
attribute("version", "2.0")
@ -65,6 +67,8 @@ object OPMLParser {
outputStream.write(opml.toString().toByteArray())
outputStream.flush()
outputStream.close()
emitter.onComplete()
}
}
}

View File

@ -1,31 +1,30 @@
package com.readrops.api.services
import com.readrops.api.services.fever.FeverCredentials
import com.readrops.api.services.greader.GReaderCredentials
import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials
import com.readrops.api.services.nextcloudnews.NextcloudNewsService
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
abstract class Credentials(val authorization: String?, val url: String) {
companion object {
@JvmStatic
fun toCredentials(account: Account): Credentials {
val endPoint = getEndPoint(account.type!!)
val endPoint = getEndPoint(account.accountType!!)
return when (account.type) {
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsCredentials(account.login, account.password, account.url + endPoint)
AccountType.FRESHRSS, AccountType.GREADER -> GReaderCredentials(account.token, account.url + endPoint)
AccountType.FEVER -> FeverCredentials(account.login, account.password, account.url + endPoint)
return when (account.accountType) {
AccountType.NEXTCLOUD_NEWS -> NextNewsCredentials(account.login, account.password, account.url + endPoint)
AccountType.FRESHRSS -> FreshRSSCredentials(account.token, account.url + endPoint)
else -> throw IllegalArgumentException("Unknown account type")
}
}
private fun getEndPoint(accountType: AccountType): String {
return when (accountType) {
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsService.END_POINT
AccountType.FRESHRSS -> "api/greader.php/"
AccountType.FEVER, AccountType.GREADER -> ""
AccountType.FRESHRSS -> FreshRSSService.END_POINT
AccountType.NEXTCLOUD_NEWS -> NextNewsService.END_POINT
else -> throw IllegalArgumentException("Unknown account type")
}
}

View File

@ -1,15 +0,0 @@
package com.readrops.api.services
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
data class DataSourceResult(
var items: List<Item> = mutableListOf(),
var starredItems: List<Item> = mutableListOf(),
var feeds: List<Feed> = listOf(),
var folders: List<Folder> = listOf(),
var unreadIds: List<String> = listOf(),
var readIds: List<String> = listOf(),
var starredIds: List<String> = listOf(),
)

View File

@ -0,0 +1,15 @@
package com.readrops.api.services
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 starredItems: List<Item> = mutableListOf(),
var feeds: List<Feed> = listOf(),
var folders: List<Folder> = listOf(),
var unreadIds: List<String>? = null,
var readIds: List<String>? = null,
var starredIds: List<String>? = null,
var isError: Boolean = false
)

View File

@ -1,7 +0,0 @@
package com.readrops.api.services.fever
import com.readrops.api.services.Credentials
class FeverCredentials(login: String?, password: String?, url: String) :
Credentials(/*(login != null && password != null)
.let { "api_key=" + ApiUtils.md5hash("$login:p$password") }*/null, url)

View File

@ -1,127 +0,0 @@
package com.readrops.api.services.fever
import com.readrops.api.services.SyncType
import com.readrops.api.services.fever.adapters.FeverAPIAdapter
import com.readrops.api.utils.ApiUtils
import com.squareup.moshi.Moshi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import okhttp3.MultipartBody
class FeverDataSource(private val service: FeverService) {
suspend fun login(login: String, password: String): Boolean {
val response = service.login(getFeverRequestBody(login, password))
val adapter = Moshi.Builder()
.add(Boolean::class.java, FeverAPIAdapter())
.build()
.adapter(Boolean::class.java)
return adapter.fromJson(response.source())!!
}
suspend fun synchronize(
login: String,
password: String,
syncType: SyncType,
lastSinceId: String,
): FeverSyncResult = with(CoroutineScope(Dispatchers.IO)) {
val body = getFeverRequestBody(login, password)
if (syncType == SyncType.INITIAL_SYNC) {
return FeverSyncResult().apply {
awaitAll(
async { feverFeeds = service.getFeeds(body) },
async { folders = service.getFolders(body) },
async {
unreadIds = service.getUnreadItemsIds(body)
.reversed()
.take(MAX_ITEMS_IDS)
var maxId = unreadIds.maxOfOrNull { it }
items = buildList {
for (index in 0 until INITIAL_SYNC_ITEMS_REQUESTS_COUNT) {
val newItems = service.getItems(body, maxId, null)
if (newItems.isEmpty()) break
// always take the lowest id
maxId = newItems.minOfOrNull { it.remoteId!!.toLong() }.toString()
addAll(newItems)
}
}
sinceId = unreadIds.maxOfOrNull { it.toLong() } ?: 0
},
async { starredIds = service.getStarredItemsIds(body) },
async { favicons = service.getFavicons(body) }
)
}
} else {
return FeverSyncResult().apply {
awaitAll(
async { folders = service.getFolders(body) },
async { feverFeeds = service.getFeeds(body) },
async { unreadIds = service.getUnreadItemsIds(body) },
async { starredIds = service.getStarredItemsIds(body) },
async { favicons = service.getFavicons(body) },
async {
items = buildList {
var localSinceId = lastSinceId
while (true) {
val newItems = service.getItems(body, null, localSinceId)
if (newItems.isEmpty()) break
// always take the highest id
localSinceId =
newItems.maxOfOrNull { it.remoteId!!.toLong() }.toString()
addAll(newItems)
}
sinceId = if (items.isNotEmpty()) {
items.maxOfOrNull { it.remoteId!!.toLong() }!!
} else {
localSinceId.toLong()
}
}
}
)
}
}
}
suspend fun setItemState(login: String, password: String, action: String, id: String) {
val body = getFeverRequestBody(login, password)
service.updateItemState(body, action, id)
}
private fun getFeverRequestBody(login: String, password: String): MultipartBody {
val credentials = ApiUtils.md5hash("$login:$password")
return MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("api_key", credentials)
.build()
}
companion object {
private const val MAX_ITEMS_IDS = 1000
private const val INITIAL_SYNC_ITEMS_REQUESTS_COUNT = 20 // (1000 items max)
}
}
sealed class ItemAction(val value: String) {
sealed class ReadStateAction(value: String) : ItemAction(value) {
data object ReadAction : ReadStateAction("read")
data object UnreadAction : ReadStateAction("unread")
}
sealed class StarStateAction(value: String) : ItemAction(value) {
data object StarAction : StarStateAction("saved")
data object UnstarAction : StarStateAction("unsaved")
}
}

View File

@ -1,45 +0,0 @@
package com.readrops.api.services.fever
import com.readrops.api.services.fever.adapters.Favicon
import com.readrops.api.services.fever.adapters.FeverFeeds
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import okhttp3.MultipartBody
import okhttp3.ResponseBody
import retrofit2.http.Body
import retrofit2.http.POST
import retrofit2.http.Query
interface FeverService {
@POST("?api")
suspend fun login(@Body body: MultipartBody): ResponseBody
@POST("?feeds")
suspend fun getFeeds(@Body body: MultipartBody): FeverFeeds
@POST("?groups")
suspend fun getFolders(@Body body: MultipartBody): List<Folder>
@POST("?favicons")
suspend fun getFavicons(@Body body: MultipartBody): List<Favicon>
@POST("?items")
suspend fun getItems(@Body body: MultipartBody, @Query("max_id") maxId: String?,
@Query("since_id") sinceId: String?): List<Item>
@POST("?unread_item_ids")
suspend fun getUnreadItemsIds(@Body body: MultipartBody): List<String>
@POST("?saved_item_ids")
suspend fun getStarredItemsIds(@Body body: MultipartBody): List<String>
@POST("?mark=item")
suspend fun updateItemState(@Body body: MultipartBody, @Query("as") action: String,
@Query("id") id: String)
companion object {
const val END_POINT = "api/fever.php/"
}
}

View File

@ -1,16 +0,0 @@
package com.readrops.api.services.fever
import com.readrops.api.services.fever.adapters.Favicon
import com.readrops.api.services.fever.adapters.FeverFeeds
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
data class FeverSyncResult(
var feverFeeds: FeverFeeds = FeverFeeds(),
var folders: List<Folder> = listOf(),
var items: List<Item> = listOf(),
var unreadIds: List<String> = listOf(),
var starredIds: List<String> = listOf(),
var favicons: List<Favicon> = listOf(),
var sinceId: Long = 0,
)

View File

@ -1,37 +0,0 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.toBoolean
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import com.squareup.moshi.ToJson
class FeverAPIAdapter : JsonAdapter<Boolean>() {
@ToJson
override fun toJson(writer: JsonWriter, value: Boolean?) {
// useless here
}
@FromJson
override fun fromJson(reader: JsonReader): Boolean = with(reader) {
return try {
beginObject()
var authenticated = false
while (hasNext()) {
when (nextName()) {
"auth" -> authenticated = nextInt().toBoolean()
else -> skipValue()
}
}
endObject()
authenticated
} catch (e: Exception) {
throw ParseException(e.message)
}
}
}

View File

@ -1,79 +0,0 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
data class Favicon(
val id: Int,
val data: ByteArray
)
class FeverFaviconsAdapter {
@ToJson
fun toJson(favicons: List<Favicon>) = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Favicon> = with(reader) {
return try {
val favicons = arrayListOf<Favicon>()
beginObject()
while (hasNext()) {
when (nextName()) {
"favicons" -> {
beginArray()
while (hasNext()) {
beginObject()
parseFavicon(reader)?.let { favicons += it }
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
favicons
} catch (e: Exception) {
throw ParseException(e.message)
}
}
@OptIn(ExperimentalEncodingApi::class)
private fun parseFavicon(reader: JsonReader): Favicon? = with(reader) {
var id = 0
var data: ByteArray? = null
while (hasNext()) {
when (selectName(NAMES)) {
0 -> id = nextInt()
1 -> data = Base64.decode(nextString().substringAfter("base64,"))
else -> skipValue()
}
}
if (id > 0 && data != null) {
return Favicon(
id = id,
data = data,
)
} else {
null
}
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "data")
}
}

View File

@ -1,111 +0,0 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
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.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
data class FeverFeeds(
val feeds: List<Feed> = listOf(),
val favicons: Map<Int, String> = mapOf(), // <faviconId, feedRemoteId>
val feedsGroups: Map<Int, List<Int>> = emptyMap()
)
class FeverFeedsAdapter : JsonAdapter<FeverFeeds>() {
override fun toJson(writer: JsonWriter, value: FeverFeeds?) {
// not useful here
}
@SuppressLint("CheckResult")
override fun fromJson(reader: JsonReader): FeverFeeds = with(reader) {
return try {
val feeds = arrayListOf<Feed>()
val favicons = mutableMapOf<Int, String>()
val feedsGroups = mutableMapOf<Int, List<Int>>()
beginObject()
while (hasNext()) {
when (nextName()) {
"feeds" -> {
beginArray()
while (hasNext()) {
beginObject()
feeds += parseFeed(reader, favicons)
endObject()
}
endArray()
}
"feeds_groups" -> {
beginArray()
while (hasNext()) {
beginObject()
val (folderId, feedsIds) = parseFeedsGroups(reader)
folderId?.let { feedsGroups[it] = feedsIds }
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
FeverFeeds(
feeds = feeds,
favicons = favicons,
feedsGroups = feedsGroups
)
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseFeed(reader: JsonReader, favicons: MutableMap<Int, String>): Feed = with(reader) {
val feed = Feed()
while (hasNext()) {
with(feed) {
when (selectName(NAMES)) {
0 -> remoteId = nextInt().toString()
1 -> favicons[nextInt()] = remoteId!!
2 -> name = nextNonEmptyString()
3 -> url = nextNonEmptyString()
4 -> siteUrl = nextNullableString()
else -> skipValue()
}
}
}
return feed
}
private fun parseFeedsGroups(reader: JsonReader): Pair<Int?, List<Int>> = with(reader) {
var folderId: Int? = null
val feedsIds = mutableListOf<Int>()
while (hasNext()) {
when (selectName(JsonReader.Options.of("group_id", "feed_ids"))) {
0 -> folderId = nextInt()
1 -> feedsIds += nextNonEmptyString().split(",").map { it.toInt() }
else -> skipValue()
}
}
folderId to feedsIds
}
companion object {
val NAMES: JsonReader.Options =
JsonReader.Options.of("id", "favicon_id", "title", "url", "site_url")
}
}

View File

@ -1,61 +0,0 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.db.entities.Folder
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class FeverFoldersAdapter {
@ToJson
fun toJson(folders: List<Folder>) = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Folder> = with(reader) {
return try {
val folders = arrayListOf<Folder>()
beginObject()
while (hasNext()) {
when (nextName()) {
"groups" -> {
beginArray()
while (hasNext()) {
beginObject()
val folder = Folder()
while (hasNext()) {
with(folder) {
when (selectName(NAMES)) {
0 -> remoteId = nextInt().toString()
1 -> name = nextNonEmptyString()
}
}
}
folders += folder
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
folders
} catch (e: Exception) {
throw ParseException(e.message)
}
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "title")
}
}

View File

@ -1,85 +0,0 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.api.utils.extensions.toBoolean
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class FeverItemsAdapter {
@ToJson
fun toJson(items: List<Item>) = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Item> = with(reader) {
return try {
val items = arrayListOf<Item>()
beginObject()
while (hasNext()) {
when (nextName()) {
"items" -> {
beginArray()
while (hasNext()) {
beginObject()
items += parseItem(reader)
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
items
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseItem(reader: JsonReader): Item = with(reader) {
val item = Item()
while (hasNext()) {
with(item) {
when (selectName(NAMES)) {
0 -> {
remoteId = if (reader.peek() == JsonReader.Token.STRING) {
nextNonEmptyString()
} else {
nextInt().toString()
}
}
1 -> feedRemoteId = nextNonEmptyString()
2 -> title = nextNonEmptyString()
3 -> author = nextNullableString()
4 -> content = nextNullableString()
5 -> link = nextNullableString()
6 -> isRead = nextInt().toBoolean()
7 -> isStarred = nextInt().toBoolean()
8 -> pubDate = DateUtils.fromEpochSeconds(nextLong())
else -> skipValue()
}
}
}
return item
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of(
"id", "feed_id", "title", "author", "html", "url",
"is_read", "is_saved", "created_on_time"
)
}
}

View File

@ -1,34 +0,0 @@
package com.readrops.api.services.fever.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class FeverItemsIdsAdapter {
@ToJson
fun toJson(ids: List<String>) = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<String> = with(reader) {
return try {
beginObject()
val ids = arrayListOf<String>()
while (hasNext()) {
when (nextName()) {
"unread_item_ids" -> ids.addAll(nextString().split(","))
else -> skipValue()
}
}
endObject()
ids
} catch (e: Exception) {
throw ParseException(e.message)
}
}
}

View File

@ -1,8 +1,8 @@
package com.readrops.api.services.greader
package com.readrops.api.services.freshrss
import com.readrops.api.services.Credentials
class GReaderCredentials(token: String?, url: String) :
class FreshRSSCredentials(token: String?, url: String) :
Credentials(token?.let { AUTH_PREFIX + it }, url) {
companion object {

View File

@ -0,0 +1,316 @@
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.adapters.FreshRSSUserInfo;
import com.readrops.db.entities.Feed;
import com.readrops.db.entities.Folder;
import com.readrops.db.entities.Item;
import java.io.StringReader;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Properties;
import io.reactivex.Completable;
import io.reactivex.Single;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
public class FreshRSSDataSource {
private static final int MAX_ITEMS = 2500;
private static final int MAX_STARRED_ITEMS = 1000;
public static final String GOOGLE_READ = "user/-/state/com.google/read";
public static final String GOOGLE_UNREAD = "user/-/state/com.google/unread";
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/";
private final FreshRSSService api;
public FreshRSSDataSource(FreshRSSService api) {
this.api = api;
}
/**
* Call token API to generate a new token from account credentials
*
* @param login login
* @param password password
* @return the generated token
*/
public Single<String> login(@NonNull String login, @NonNull String password) {
RequestBody requestBody = new MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("Email", login)
.addFormDataPart("Passwd", password)
.build();
return api.login(requestBody)
.flatMap(response -> {
Properties properties = new Properties();
properties.load(new StringReader(response.string()));
return Single.just(properties.getProperty("Auth"));
});
}
/**
* Get a write token to modify feeds, folders and items on the server
*
* @return the write token generated by the server
*/
public Single<String> getWriteToken() {
return api.getWriteToken()
.flatMap(responseBody -> Single.just(responseBody.string()));
}
/**
* Retrieve user information : name, email, id, profileId
*
* @return user information
*/
public Single<FreshRSSUserInfo> getUserInfo() {
return api.getUserInfo();
}
/**
* Synchronize feeds, folders, items and push read/unread items
*
* @param syncType INITIAL or CLASSIC
* @param syncData data to sync (read/unread items ids, lastModified timestamp)
* @param writeToken token for making modifications on the server
* @return the result of the synchronization
*/
public Single<SyncResult> sync(@NonNull SyncType syncType, @NonNull FreshRSSSyncData syncData, @NonNull String writeToken) {
if (syncType == SyncType.INITIAL_SYNC) {
return Single.zip(setItemsReadState(syncData, writeToken).toSingleDefault(""),
setItemsStarState(syncData, writeToken).toSingleDefault(""),
getFolders(),
getFeeds(),
getItems(Arrays.asList(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null),
getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS), // unread items ids
getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS), // starred items ids
getStarredItems(MAX_STARRED_ITEMS),
(readState, starState, folders, feeds, items, unreadItemsIds, starredItemsIds, starredItems) ->
new SyncResult(items, starredItems, feeds, folders, unreadItemsIds, Collections.emptyList(), starredItemsIds, false)
);
} else {
return Single.zip(setItemsReadState(syncData, writeToken).toSingleDefault(""),
setItemsStarState(syncData, writeToken).toSingleDefault(""),
getFolders(),
getFeeds(),
getItems(null, MAX_ITEMS, syncData.getLastModified()),
getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS), // unread items ids
getItemsIds(GOOGLE_UNREAD, GOOGLE_READING_LIST, MAX_ITEMS), // read items ids
getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS), // starred items ids
(readState, starState, folders, feeds, items, unreadItemsIds, readItemsIds, starredItemsIds) ->
new SyncResult(items, Collections.emptyList(), feeds, folders, unreadItemsIds, readItemsIds, starredItemsIds, false)
);
}
}
/**
* Fetch the feeds folders
*
* @return the feeds folders
*/
public Single<List<Folder>> getFolders() {
return api.getFolders();
}
/**
* Fetch the feeds
*
* @return the feeds
*/
public Single<List<Feed>> getFeeds() {
return api.getFeeds();
}
/**
* Fetch the items
*
* @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 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);
}
/**
* Mark items read or unread
*
* @param read true for read, false for unread
* @param itemIds items ids to mark
* @param token token for modifications
* @return Completable
*/
public Completable setItemsReadState(boolean read, @NonNull List<String> itemIds, @NonNull String token) {
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 setItemsStarState(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);
}
}
/**
* Create a new feed
*
* @param token token for modifications
* @param feedUrl url of the feed to parse
* @return Completable
*/
public Completable createFeed(@NonNull String token, @NonNull String feedUrl) {
return api.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe");
}
/**
* Delete a feed
*
* @param token token for modifications
* @param feedUrl url of the feed to delete
* @return Completable
*/
public Completable deleteFeed(@NonNull String token, @NonNull String feedUrl) {
return api.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe");
}
/**
* Update feed title and folder
*
* @param token token for modifications
* @param feedUrl url of the feed to update
* @param title new title
* @param folderId id of the new folder
* @return Completable
*/
public Completable updateFeed(@NonNull String token, @NonNull String feedUrl, @NonNull String title, @NonNull String folderId) {
return api.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit");
}
/**
* Create a new folder
*
* @param token token for modifications
* @param tagName name of the new folder
* @return Completable
*/
public Completable createFolder(@NonNull String token, @NonNull String tagName) {
return api.createFolder(token, "user/-/label/" + tagName);
}
/**
* Update folder name
*
* @param token token for modifications
* @param folderId id of the folder
* @param name new folder name
* @return Completable
*/
public Completable updateFolder(@NonNull String token, @NonNull String folderId, @NonNull String name) {
return api.updateFolder(token, folderId, "user/-/label/" + name);
}
/**
* Delete a folder
*
* @param token token for modifications
* @param folderId id of the folder to delete
* @return Completable
*/
public Completable deleteFolder(@NonNull String token, @NonNull String folderId) {
return api.deleteFolder(token, folderId);
}
/**
* Set items star state
*
* @param syncData data containing items to mark
* @param token token for modifications
* @return A concatenation of two completable (read and unread completable)
*/
private Completable setItemsReadState(@NonNull FreshRSSSyncData syncData, @NonNull String token) {
Completable readItemsCompletable;
if (syncData.getReadItemsIds().isEmpty()) {
readItemsCompletable = Completable.complete();
} else {
readItemsCompletable = setItemsReadState(true, syncData.getReadItemsIds(), token);
}
Completable unreadItemsCompletable;
if (syncData.getUnreadItemsIds().isEmpty()) {
unreadItemsCompletable = Completable.complete();
} else {
unreadItemsCompletable = setItemsReadState(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 = setItemsStarState(true, syncData.getStarredItemsIds(), token);
}
Completable unstarredItemsCompletable;
if (syncData.getUnstarredItemsIds().isEmpty()) {
unstarredItemsCompletable = Completable.complete();
} else {
unstarredItemsCompletable = setItemsStarState(false, syncData.getUnstarredItemsIds(), token);
}
return starredItemsCompletable.concatWith(unstarredItemsCompletable);
}
}

View File

@ -0,0 +1,70 @@
package com.readrops.api.services.freshrss
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import io.reactivex.Completable
import io.reactivex.Single
import okhttp3.RequestBody
import okhttp3.ResponseBody
import retrofit2.http.*
interface FreshRSSService {
@POST("accounts/ClientLogin")
fun login(@Body body: RequestBody?): Single<ResponseBody?>?
@get:GET("reader/api/0/token")
val writeToken: Single<ResponseBody>
@get:GET("reader/api/0/user-info")
val userInfo: Single<FreshRSSUserInfo>
@get:GET("reader/api/0/subscription/list?output=json")
val feeds: Single<List<Feed>>
@get:GET("reader/api/0/tag/list?output=json")
val folders: Single<List<Folder>>
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list")
fun getItems(@Query("xt") excludeTarget: List<String>?, @Query("n") max: Int,
@Query("ot") lastModified: Long?): Single<List<Item>>
@GET("reader/api/0/stream/contents/user/-/state/com.google/starred")
fun getStarredItems(@Query("n") max: Int): Single<List<Item>>
@GET("reader/api/0/stream/items/ids")
fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?,
@Query("n") max: Int): Single<List<String>>
@FormUrlEncoded
@POST("reader/api/0/edit-tag")
fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?,
@Field("r") removeAction: String?, @Field("i") itemIds: List<String>): Completable
@FormUrlEncoded
@POST("reader/api/0/subscription/edit")
fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String): Completable
@FormUrlEncoded
@POST("reader/api/0/subscription/edit")
fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String,
@Field("a") folderId: String, @Field("ac") action: String): Completable
@FormUrlEncoded
@POST("reader/api/0/edit-tag")
fun createFolder(@Field("T") token: String, @Field("a") tagName: String): Completable
@FormUrlEncoded
@POST("reader/api/0/rename-tag")
fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String): Completable
@FormUrlEncoded
@POST("reader/api/0/disable-tag")
fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String): Completable
companion object {
const val END_POINT = "/api/greader.php/"
}
}

View File

@ -0,0 +1,9 @@
package com.readrops.api.services.freshrss
data class FreshRSSSyncData(
var lastModified: Long = 0,
var readItemsIds: List<String> = listOf(),
var unreadItemsIds: List<String> = listOf(),
var starredItemsIds: List<String> = listOf(),
var unstarredItemsIds: List<String> = listOf(),
)

View File

@ -0,0 +1,121 @@
package com.readrops.api.services.freshrss
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
import com.readrops.db.entities.Item
import okhttp3.MultipartBody
import java.io.StringReader
import java.util.Properties
class NewFreshRSSDataSource(private val service: NewFreshRSSService) {
suspend fun login(login: String, password: String): String {
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("Email", login)
.addFormDataPart("Passwd", password)
.build()
val response = service.login(requestBody)
val properties = Properties()
properties.load(StringReader(response.string()))
response.close()
return properties.getProperty("Auth")
}
suspend fun getWriteToken(): String = service.getWriteToken().string()
suspend fun getUserInfo(): FreshRSSUserInfo = service.userInfo()
suspend fun sync() {
}
suspend fun getFolders() = service.getFolders()
suspend fun getFeeds() = service.getFeeds()
suspend fun getItems(excludeTargets: List<String>, max: Int, lastModified: Long): List<Item> {
return service.getItems(excludeTargets, max, lastModified)
}
suspend fun getStarredItems(max: Int) = service.getStarredItems(max)
suspend fun getItemsIds(excludeTarget: String, includeTarget: String, max: Int): List<String> {
return service.getItemsIds(excludeTarget, includeTarget, max)
}
private suspend fun setItemsReadState(read: Boolean, itemIds: List<String>, token: String) {
return if (read) {
service.setItemsState(token, GOOGLE_READ, null, itemIds)
} else {
service.setItemsState(token, null, GOOGLE_READ, itemIds)
}
}
private suspend fun setItemStarState(starred: Boolean, itemIds: List<String>, token: String) {
return if (starred) {
service.setItemsState(token, GOOGLE_STARRED, null, itemIds)
} else {
service.setItemsState(token, null, GOOGLE_STARRED, itemIds)
}
}
suspend fun createFeed(token: String, feedUrl: String) {
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe");
}
suspend fun deleteFeed(token: String, feedUrl: String) {
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe")
}
suspend fun updateFeed(token: String, feedUrl: String, title: String, folderId: String) {
service.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit")
}
suspend fun createFolder(token: String, tagName: String) {
service.createFolder(token, "$FOLDER_PREFIX$tagName")
}
suspend fun updateFolder(token: String, folderId: String, name: String) {
service.updateFolder(token, folderId, "$FOLDER_PREFIX$name")
}
suspend fun deleteFolder(token: String, folderId: String) {
service.deleteFolder(token, folderId)
}
suspend fun setItemsReadState(syncData: FreshRSSSyncData, token: String) {
if (syncData.readItemsIds.isNotEmpty()) {
setItemsReadState(true, syncData.readItemsIds, token)
}
if (syncData.unreadItemsIds.isNotEmpty()) {
setItemsReadState(false, syncData.unreadItemsIds, token)
}
}
suspend fun setItemsStarState(syncData: FreshRSSSyncData, token: String) {
if (syncData.starredItemsIds.isNotEmpty()) {
setItemStarState(true, syncData.starredItemsIds, token)
}
if (syncData.unstarredItemsIds.isNotEmpty()) {
setItemStarState(false, syncData.unstarredItemsIds, token)
}
}
companion object {
private const val MAX_ITEMS = 2500
private const val MAX_STARRED_ITEMS = 1000
const val GOOGLE_READ = "user/-/state/com.google/read"
const val GOOGLE_UNREAD = "user/-/state/com.google/unread"
const val GOOGLE_STARRED = "user/-/state/com.google/starred"
const val GOOGLE_READING_LIST = "user/-/state/com.google/reading-list"
const val FEED_PREFIX = "feed/"
const val FOLDER_PREFIX = "user/-/label/"
}
}

View File

@ -1,6 +1,6 @@
package com.readrops.api.services.greader
package com.readrops.api.services.freshrss
import com.readrops.api.services.greader.adapters.FreshRSSUserInfo
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
@ -13,7 +13,7 @@ import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
interface GReaderService {
interface NewFreshRSSService {
@POST("accounts/ClientLogin")
suspend fun login(@Body body: RequestBody?): ResponseBody
@ -31,49 +31,29 @@ interface GReaderService {
suspend fun getFolders(): List<Folder>
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list")
suspend fun getItems(
@Query("xt") excludeTarget: List<String>?,
@Query("n") max: Int,
@Query("ot") lastModified: Long?
): List<Item>
suspend fun getItems(@Query("xt") excludeTarget: List<String>?, @Query("n") max: Int,
@Query("ot") lastModified: Long?): List<Item>
@GET("reader/api/0/stream/contents/user/-/state/com.google/starred")
suspend fun getStarredItems(@Query("n") max: Int): List<Item>
@GET("reader/api/0/stream/items/ids")
suspend fun getItemsIds(
@Query("xt") excludeTarget: String?,
@Query("s") includeTarget: String?,
@Query("n") max: Int
): List<String>
suspend fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?,
@Query("n") max: Int): List<String>
@FormUrlEncoded
@POST("reader/api/0/edit-tag")
suspend fun setItemsState(
@Field("T") token: String,
@Field("a") addAction: String?,
@Field("r") removeAction: String?,
@Field("i") itemIds: List<String>
)
suspend fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?,
@Field("r") removeAction: String?, @Field("i") itemIds: List<String>)
@FormUrlEncoded
@POST("reader/api/0/subscription/edit")
suspend fun createOrDeleteFeed(
@Field("T") token: String,
@Field("s") feedUrl: String,
@Field("ac") action: String,
@Field("a") folderId: String?
)
suspend fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String)
@FormUrlEncoded
@POST("reader/api/0/subscription/edit")
suspend fun updateFeed(
@Field("T") token: String,
@Field("s") feedUrl: String,
@Field("t") title: String,
@Field("a") folderId: String,
@Field("ac") action: String
)
suspend fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String,
@Field("a") folderId: String, @Field("ac") action: String)
@FormUrlEncoded
@POST("reader/api/0/edit-tag")
@ -81,13 +61,13 @@ interface GReaderService {
@FormUrlEncoded
@POST("reader/api/0/rename-tag")
suspend fun updateFolder(
@Field("T") token: String,
@Field("s") folderId: String,
@Field("dest") newFolderId: String
)
suspend fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String)
@FormUrlEncoded
@POST("reader/api/0/disable-tag")
suspend fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String)
companion object {
const val END_POINT = "/api/greader.php/"
}
}

View File

@ -0,0 +1,85 @@
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.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 FreshRSSFeedsAdapter {
@ToJson
fun toJson(feeds: List<Feed>): String = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Feed> {
val feeds = mutableListOf<Feed>()
return try {
reader.beginObject()
reader.nextName() // "subscriptions", beginning of the feed array
reader.beginArray()
while (reader.hasNext()) {
reader.beginObject()
val feed = Feed()
while (reader.hasNext()) {
with(feed) {
when (reader.selectName(NAMES)) {
0 -> name = reader.nextNonEmptyString()
1 -> url = reader.nextNonEmptyString()
2 -> siteUrl = reader.nextNullableString()
3 -> iconUrl = reader.nextNullableString()
4 -> remoteId = reader.nextNonEmptyString()
5 -> remoteFolderId = getCategoryId(reader)
else -> reader.skipValue()
}
}
}
feeds += feed
reader.endObject()
}
reader.endArray()
reader.endObject()
feeds
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun getCategoryId(reader: JsonReader): String? {
var id: String? = null
reader.beginArray()
while (reader.hasNext()) {
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"id" -> id = reader.nextNullableString()
else -> reader.skipValue()
}
}
reader.endObject()
if (!id.isNullOrEmpty())
break
}
reader.endArray()
return id
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("title", "url", "htmlUrl",
"iconUrl", "id", "categories")
}
}

View File

@ -0,0 +1,67 @@
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.readrops.db.entities.Folder
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
import java.util.*
class FreshRSSFoldersAdapter {
@ToJson
fun toJson(folders: List<Folder>): String = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Folder> {
val folders = mutableListOf<Folder>()
return try {
reader.beginObject()
reader.nextName() // "tags", beginning of folder array
reader.beginArray()
while (reader.hasNext()) {
reader.beginObject()
val folder = Folder()
var type: String? = null
while (reader.hasNext()) {
with(folder) {
when (reader.selectName(NAMES)) {
0 -> {
val id = reader.nextNonEmptyString()
name = StringTokenizer(id, "/")
.toList()
.last() as String
remoteId = id
}
1 -> type = reader.nextString()
else -> reader.skipValue()
}
}
}
if (type == "folder") // add only folders and avoid tags
folders += folder
reader.endObject()
}
reader.endArray()
reader.endObject()
folders
} catch (e: Exception) {
throw ParseException(e.message)
}
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "type")
}
}

View File

@ -0,0 +1,139 @@
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.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.DateTimeZone
import org.joda.time.LocalDateTime
class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
override fun toJson(writer: JsonWriter, value: List<Item>?) {
// no need of this
}
override fun fromJson(reader: JsonReader): List<Item>? {
val items = mutableListOf<Item>()
return try {
reader.beginObject()
while (reader.hasNext()) {
if (reader.nextName() == "items") parseItems(reader, items) else reader.skipValue()
}
reader.endObject()
items
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseItems(reader: JsonReader, items: MutableList<Item>) {
reader.beginArray()
while (reader.hasNext()) {
val item = Item()
reader.beginObject()
while (reader.hasNext()) {
with(item) {
when (reader.selectName(NAMES)) {
0 -> remoteId = reader.nextNonEmptyString()
1 -> pubDate = LocalDateTime(reader.nextLong() * 1000L,
DateTimeZone.getDefault())
2 -> title = reader.nextNonEmptyString()
3 -> content = getContent(reader)
4 -> link = getLink(reader)
5 -> getStates(reader, this)
6 -> feedRemoteId = getRemoteFeedId(reader)
7 -> author = reader.nextNullableString()
else -> reader.skipValue()
}
}
}
items += item
reader.endObject()
}
reader.endArray()
}
private fun getContent(reader: JsonReader): String? {
var content: String? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"content" -> content = reader.nextNullableString()
else -> reader.skipValue()
}
}
reader.endObject()
return content
}
private fun getLink(reader: JsonReader): String? {
var href: String? = null
reader.beginArray()
while (reader.hasNext()) {
reader.beginObject()
when (reader.nextName()) {
"href" -> href = reader.nextNullableString()
else -> reader.skipValue()
}
reader.endObject()
}
reader.endArray()
return href
}
private fun getStates(reader: JsonReader, item: Item) {
reader.beginArray()
while (reader.hasNext()) {
when (reader.nextString()) {
GOOGLE_READ -> item.isRead = true
GOOGLE_STARRED -> item.isStarred = true
else -> reader.skipValue()
}
}
reader.endArray()
}
private fun getRemoteFeedId(reader: JsonReader): String? {
var remoteFeedId: String? = null
reader.beginObject()
while (reader.hasNext()) {
when (reader.nextName()) {
"streamId" -> remoteFeedId = reader.nextString()
else -> reader.skipValue()
}
}
reader.endObject()
return remoteFeedId
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "published", "title",
"summary", "alternate", "categories", "origin", "author")
val TAG: String = FreshRSSItemsAdapter::class.java.simpleName
}
}

View File

@ -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)
}
}
}

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.greader.adapters
package com.readrops.api.services.freshrss.adapters
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNullableString

View File

@ -1,165 +0,0 @@
package com.readrops.api.services.greader
import com.readrops.api.services.DataSourceResult
import com.readrops.api.services.SyncType
import com.readrops.api.services.greader.adapters.FreshRSSUserInfo
import com.readrops.db.entities.Item
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import okhttp3.MultipartBody
import java.io.StringReader
import java.util.Properties
class GReaderDataSource(private val service: GReaderService) {
suspend fun login(login: String, password: String): String {
val requestBody = MultipartBody.Builder()
.setType(MultipartBody.FORM)
.addFormDataPart("Email", login)
.addFormDataPart("Passwd", password)
.build()
val response = service.login(requestBody)
val properties = Properties()
properties.load(StringReader(response.string()))
response.close()
return properties.getProperty("Auth")
}
suspend fun getWriteToken(): String = service.getWriteToken().string()
suspend fun getUserInfo(): FreshRSSUserInfo = service.userInfo()
suspend fun synchronize(
syncType: SyncType,
syncData: GReaderSyncData,
writeToken: String
): DataSourceResult = with(CoroutineScope(Dispatchers.IO)) {
return if (syncType == SyncType.INITIAL_SYNC) {
DataSourceResult().apply {
awaitAll(
async { folders = getFolders() },
async { feeds = getFeeds() },
async {
items = getItems(listOf(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null)
},
async { starredItems = getStarredItems(MAX_STARRED_ITEMS) },
async { unreadIds = getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS) },
async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) }
)
}
} else {
DataSourceResult().apply {
awaitAll(
async { setItemsReadState(syncData, writeToken) },
async { setItemsStarState(syncData, writeToken) },
)
awaitAll(
async { folders = getFolders() },
async { feeds = getFeeds() },
async { items = getItems(null, MAX_ITEMS, syncData.lastModified) },
async { unreadIds = getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS) },
async {
readIds = getItemsIds(GOOGLE_UNREAD, GOOGLE_READING_LIST, MAX_ITEMS)
},
async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) }
)
}
}
}
suspend fun getFolders() = service.getFolders()
suspend fun getFeeds() = service.getFeeds()
suspend fun getItems(excludeTargets: List<String>?, max: Int, lastModified: Long?): List<Item> {
return service.getItems(excludeTargets, max, lastModified)
}
suspend fun getStarredItems(max: Int) = service.getStarredItems(max)
suspend fun getItemsIds(excludeTarget: String?, includeTarget: String, max: Int): List<String> {
return service.getItemsIds(excludeTarget, includeTarget, max)
}
private suspend fun setItemsReadState(read: Boolean, itemIds: List<String>, token: String) {
return if (read) {
service.setItemsState(token, GOOGLE_READ, null, itemIds)
} else {
service.setItemsState(token, null, GOOGLE_READ, itemIds)
}
}
private suspend fun setItemStarState(starred: Boolean, itemIds: List<String>, token: String) {
return if (starred) {
service.setItemsState(token, GOOGLE_STARRED, null, itemIds)
} else {
service.setItemsState(token, null, GOOGLE_STARRED, itemIds)
}
}
suspend fun createFeed(token: String, feedUrl: String, folderId: String?) {
// no feed here of the folder prefix for the folder id
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe", folderId)
}
suspend fun deleteFeed(token: String, feedUrl: String) {
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe", null)
}
suspend fun updateFeed(token: String, feedUrl: String, title: String, folderId: String) {
service.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit")
}
suspend fun createFolder(token: String, tagName: String) {
service.createFolder(token, "$FOLDER_PREFIX$tagName")
}
suspend fun updateFolder(token: String, folderId: String, name: String) {
service.updateFolder(token, folderId, "$FOLDER_PREFIX$name")
}
suspend fun deleteFolder(token: String, folderId: String) {
service.deleteFolder(token, folderId)
}
private suspend fun setItemsReadState(syncData: GReaderSyncData, token: String) {
if (syncData.readIds.isNotEmpty()) {
setItemsReadState(true, syncData.readIds, token)
}
if (syncData.unreadIds.isNotEmpty()) {
setItemsReadState(false, syncData.unreadIds, token)
}
}
private suspend fun setItemsStarState(syncData: GReaderSyncData, token: String) {
if (syncData.starredIds.isNotEmpty()) {
setItemStarState(true, syncData.starredIds, token)
}
if (syncData.unstarredIds.isNotEmpty()) {
setItemStarState(false, syncData.unstarredIds, token)
}
}
companion object {
private const val MAX_ITEMS = 2500
private const val MAX_STARRED_ITEMS = 1000
const val GOOGLE_READ = "user/-/state/com.google/read"
const val GOOGLE_UNREAD = "user/-/state/com.google/unread"
const val GOOGLE_STARRED = "user/-/state/com.google/starred"
const val GOOGLE_READING_LIST = "user/-/state/com.google/reading-list"
const val FEED_PREFIX = "feed/"
const val FOLDER_PREFIX = "user/-/label/"
}
}

View File

@ -1,9 +0,0 @@
package com.readrops.api.services.greader
data class GReaderSyncData(
var lastModified: Long = 0,
var readIds: List<String> = listOf(),
var unreadIds: List<String> = listOf(),
var starredIds: List<String> = listOf(),
var unstarredIds: List<String> = listOf(),
)

View File

@ -1,99 +0,0 @@
package com.readrops.api.services.greader.adapters
import android.annotation.SuppressLint
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 GReaderFeedsAdapter {
@ToJson
fun toJson(feeds: List<Feed>): String = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Feed> = with(reader) {
val feeds = mutableListOf<Feed>()
return try {
beginObject()
while (hasNext()) {
when (nextName()) {
"subscriptions" -> {
beginArray()
while (hasNext()) {
beginObject()
feeds += parseFeed(reader)
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
feeds
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseFeed(reader: JsonReader): Feed = with(reader) {
val feed = Feed()
while (hasNext()) {
with(feed) {
when (selectName(NAMES)) {
0 -> name = nextNonEmptyString()
1 -> url = nextNonEmptyString()
2 -> siteUrl = nextNullableString()
3 -> iconUrl = nextNullableString()
4 -> remoteId = nextNonEmptyString()
5 -> remoteFolderId = getCategoryId(reader)
else -> skipValue()
}
}
}
return feed
}
private fun getCategoryId(reader: JsonReader): String? = with(reader) {
var id: String? = null
beginArray()
while (hasNext()) {
beginObject()
while (hasNext()) {
when (nextName()) {
"id" -> id = nextNullableString()
else -> skipValue()
}
}
endObject()
if (!id.isNullOrEmpty())
break
}
endArray()
return id
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of(
"title", "url", "htmlUrl",
"iconUrl", "id", "categories"
)
}
}

View File

@ -1,81 +0,0 @@
package com.readrops.api.services.greader.adapters
import android.annotation.SuppressLint
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.db.entities.Folder
import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
import java.util.StringTokenizer
class GReaderFoldersAdapter {
@ToJson
fun toJson(folders: List<Folder>): String = ""
@SuppressLint("CheckResult")
@FromJson
fun fromJson(reader: JsonReader): List<Folder> = with(reader) {
val folders = mutableListOf<Folder>()
return try {
beginObject()
while (hasNext()) {
when (nextName()) {
"tags" -> {
beginArray()
while (hasNext()) {
beginObject()
parseFolder(reader)?.let { folders += it }
endObject()
}
endArray()
}
else -> skipValue()
}
}
endObject()
folders
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseFolder(reader: JsonReader): Folder? = with(reader) {
val folder = Folder()
var type: String? = null
while (hasNext()) {
with(folder) {
when (selectName(NAMES)) {
0 -> {
val id = nextNonEmptyString()
name = StringTokenizer(id, "/")
.toList()
.last() as String
remoteId = id
}
1 -> type = nextString()
else -> skipValue()
}
}
}
// add only folders and avoid tags
if (type == "folder") {
folder
} else {
null
}
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "type")
}
}

View File

@ -1,139 +0,0 @@
package com.readrops.api.services.greader.adapters
import com.readrops.api.services.greader.GReaderDataSource.Companion.GOOGLE_READ
import com.readrops.api.services.greader.GReaderDataSource.Companion.GOOGLE_STARRED
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.readrops.db.util.DateUtils
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
class GReaderItemsAdapter : JsonAdapter<List<Item>>() {
override fun toJson(writer: JsonWriter, value: List<Item>?) {
// no need of this
}
override fun fromJson(reader: JsonReader): List<Item> = with(reader) {
val items = mutableListOf<Item>()
return try {
beginObject()
while (hasNext()) {
when (nextName()) {
"items" -> parseItems(reader, items)
else -> skipValue()
}
}
endObject()
items
} catch (e: Exception) {
throw ParseException(e.message)
}
}
private fun parseItems(reader: JsonReader, items: MutableList<Item>) = with(reader) {
beginArray()
while (hasNext()) {
val item = Item()
beginObject()
while (hasNext()) {
with(item) {
when (selectName(NAMES)) {
0 -> remoteId = nextNonEmptyString()
1 -> pubDate = DateUtils.fromEpochSeconds(nextLong())
2 -> title = nextNonEmptyString()
3 -> content = getContent(reader)
4 -> link = getLink(reader)
5 -> getStates(reader, this)
6 -> feedRemoteId = getRemoteFeedId(reader)
7 -> author = nextNullableString()
else -> skipValue()
}
}
}
items += item
endObject()
}
endArray()
}
private fun getContent(reader: JsonReader): String? = with(reader) {
var content: String? = null
beginObject()
while (hasNext()) {
when (nextName()) {
"content" -> content = nextNullableString()
else -> skipValue()
}
}
endObject()
return content
}
private fun getLink(reader: JsonReader): String? = with(reader) {
var href: String? = null
beginArray()
while (hasNext()) {
beginObject()
while (hasNext()) {
when (nextName()) {
"href" -> href = nextString()
else -> skipValue()
}
}
endObject()
}
endArray()
return href
}
private fun getStates(reader: JsonReader, item: Item) = with(reader) {
beginArray()
while (hasNext()) {
when (nextString()) {
GOOGLE_READ -> item.isRead = true
GOOGLE_STARRED -> item.isStarred = true
}
}
endArray()
}
private fun getRemoteFeedId(reader: JsonReader): String? = with(reader) {
var remoteFeedId: String? = null
beginObject()
while (hasNext()) {
when (nextName()) {
"streamId" -> remoteFeedId = nextString()
else -> skipValue()
}
}
endObject()
return remoteFeedId
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of(
"id", "published", "title", "summary", "alternate", "categories", "origin", "author"
)
}
}

View File

@ -1,56 +0,0 @@
package com.readrops.api.services.greader.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 GReaderItemsIdsAdapter : 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()
while (hasNext()) {
when (nextName()) {
"itemRefs" -> {
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()
}
else -> skipValue()
}
}
endObject()
ids
} catch (e: Exception) {
throw ParseException(e.message)
}
}
}

View File

@ -0,0 +1,8 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.api.services.Credentials
class NextNewsCredentials(login: String?, password: String?, url: String):
Credentials((login != null && password != null).let {
okhttp3.Credentials.basic(login!!, password!!)
}, url)

View File

@ -0,0 +1,300 @@
package com.readrops.api.services.nextcloudnews;
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.api.utils.extensions.KonsumerExtensionsKt;
import com.readrops.db.entities.Feed;
import com.readrops.db.entities.Folder;
import com.readrops.db.entities.Item;
import com.readrops.db.entities.account.Account;
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.OkHttpClient;
import okhttp3.Request;
import retrofit2.Response;
public class NextNewsDataSource {
private static final String TAG = NextNewsDataSource.class.getSimpleName();
private static final int MAX_ITEMS = 5000;
private static final int MAX_STARRED_ITEMS = 1000;
private NextNewsService api;
public NextNewsDataSource(NextNewsService api) {
this.api = api;
}
@Nullable
public String login(OkHttpClient client, Account account) throws IOException {
Request request = new Request.Builder()
.url(account.getUrl() + "/ocs/v1.php/cloud/users/" + account.getLogin())
.addHeader("OCS-APIRequest", "true")
.build();
okhttp3.Response response = client.newCall(request).execute();
String displayName = new NextNewsUserAdapter().fromXml(KonsumerExtensionsKt
.instantiateKonsumer(response.body().byteStream()));
response.body().close();
return displayName;
}
@Nullable
public List<Feed> createFeed(String url, int folderId) throws IOException, UnknownFormatException {
Response<List<Feed>> response = api.createFeed(url, folderId).execute();
if (!response.isSuccessful()) {
if (response.code() == ApiUtils.HTTP_UNPROCESSABLE)
throw new UnknownFormatException();
else
return null;
}
return response.body();
}
public SyncResult sync(@NonNull SyncType syncType, @Nullable NextNewsSyncData data) throws IOException {
SyncResult syncResult = new SyncResult();
switch (syncType) {
case INITIAL_SYNC:
initialSync(syncResult);
break;
case CLASSIC_SYNC:
if (data == null)
throw new NullPointerException("NextNewsSyncData can't be null");
classicSync(syncResult, data);
break;
}
return syncResult;
}
private void initialSync(SyncResult syncResult) throws IOException {
getFeedsAndFolders(syncResult);
// unread items
Response<List<Item>> itemsResponse = api.getItems(ItemQueryType.ALL.value, false, MAX_ITEMS).execute();
List<Item> itemList = itemsResponse.body();
if (!itemsResponse.isSuccessful())
syncResult.setError(true);
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(), ItemQueryType.ALL.value).execute();
List<Item> itemList = itemsResponse.body();
if (!itemsResponse.isSuccessful())
syncResult.setError(true);
if (itemList != null)
syncResult.setItems(itemList);
}
private void getFeedsAndFolders(SyncResult syncResult) throws IOException {
Response<List<Feed>> feedResponse = api.getFeeds().execute();
List<Feed> feedList = feedResponse.body();
if (!feedResponse.isSuccessful())
syncResult.setError(true);
Response<List<Folder>> folderResponse = api.getFolders().execute();
List<Folder> folderList = folderResponse.body();
if (!folderResponse.isSuccessful())
syncResult.setError(true);
if (folderList != null)
syncResult.setFolders(folderList);
if (feedList != null)
syncResult.setFeeds(feedList);
}
private void putModifiedItems(NextNewsSyncData data, SyncResult syncResult) throws IOException {
setReadState(data.getReadItems(), syncResult, StateType.READ);
setReadState(data.getUnreadItems(), syncResult, StateType.UNREAD);
setStarState(data.getStarredItems(), syncResult, StateType.STAR);
setStarState(data.getUnstarredItems(), syncResult, StateType.UNSTAR);
}
public List<Folder> createFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException {
Map<String, String> folderNameMap = new HashMap<>();
folderNameMap.put("name", folder.getName());
Response<List<Folder>> foldersResponse = api.createFolder(folderNameMap).execute();
if (foldersResponse.isSuccessful())
return foldersResponse.body();
else if (foldersResponse.code() == ApiUtils.HTTP_UNPROCESSABLE)
throw new UnknownFormatException();
else if (foldersResponse.code() == ApiUtils.HTTP_CONFLICT)
throw new ConflictException();
else
return new ArrayList<>();
}
public boolean deleteFolder(Folder folder) throws IOException {
Response response = api.deleteFolder(Integer.parseInt(folder.getRemoteId())).execute();
if (response.isSuccessful())
return true;
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
throw new Resources.NotFoundException();
else
return false;
}
public boolean renameFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException {
Map<String, String> folderNameMap = new HashMap<>();
folderNameMap.put("name", folder.getName());
Response response = api.renameFolder(Integer.parseInt(folder.getRemoteId()), folderNameMap).execute();
if (response.isSuccessful())
return true;
else {
switch (response.code()) {
case ApiUtils.HTTP_NOT_FOUND:
throw new Resources.NotFoundException();
case ApiUtils.HTTP_UNPROCESSABLE:
throw new UnknownFormatException();
case ApiUtils.HTTP_CONFLICT:
throw new ConflictException();
default:
return false;
}
}
}
public boolean deleteFeed(int feedId) throws IOException {
Response response = api.deleteFeed(feedId).execute();
if (response.isSuccessful())
return true;
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
throw new Resources.NotFoundException();
else
return false;
}
public boolean changeFeedFolder(Feed feed) throws IOException {
Map<String, Integer> folderIdMap = new HashMap<>();
folderIdMap.put("folderId", Integer.parseInt(feed.getRemoteFolderId()));
Response response = api.changeFeedFolder(Integer.parseInt(feed.getRemoteId()), folderIdMap).execute();
if (response.isSuccessful())
return true;
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
throw new Resources.NotFoundException();
else
return false;
}
public boolean renameFeed(Feed feed) throws IOException {
Map<String, String> feedTitleMap = new HashMap<>();
feedTitleMap.put("feedTitle", feed.getName());
Response response = api.renameFeed(Integer.parseInt(feed.getRemoteId()), feedTitleMap).execute();
if (response.isSuccessful())
return true;
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,
STAR,
UNSTAR
}
public enum ItemQueryType {
ALL(3),
STARRED(2);
private int value;
ItemQueryType(int value) {
this.value = value;
}
public int getValue() {
return value;
}
}
}

View File

@ -0,0 +1,58 @@
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 okhttp3.ResponseBody
import retrofit2.Call
import retrofit2.http.*
interface NextNewsService {
@GET("/ocs/v1.php/cloud/users/{userId}")
@Headers("OCS-APIRequest: true")
fun getUser(@Path("userId") userId: String): Call<ResponseBody>
@get:GET("folders")
val folders: Call<List<Folder>>
@get:GET("feeds")
val feeds: Call<List<Feed>>
@GET("items")
fun getItems(@Query("type") type: Int, @Query("getRead") read: Boolean, @Query("batchSize") batchSize: Int): Call<List<Item>>
@GET("items/updated")
fun getNewItems(@Query("lastModified") lastModified: Long, @Query("type") type: Int): Call<List<Item>>
@PUT("items/{stateType}/multiple")
fun setReadState(@Path("stateType") stateType: String, @Body itemIdsMap: Map<String, List<String>>): Call<ResponseBody>
@PUT("items/{starType}/multiple")
fun setStarState(@Path("starType") starType: String?, @Body body: Map<String?, List<Map<String, String>>>): Call<ResponseBody>
@POST("feeds")
fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int): Call<List<Feed>>
@DELETE("feeds/{feedId}")
fun deleteFeed(@Path("feedId") feedId: Int): Call<Void?>?
@PUT("feeds/{feedId}/move")
fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map<String, Int>): Call<ResponseBody>
@PUT("feeds/{feedId}/rename")
fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map<String, String>): Call<ResponseBody>
@POST("folders")
fun createFolder(@Body folderName: Map<String, String>): Call<List<Folder>>
@DELETE("folders/{folderId}")
fun deleteFolder(@Path("folderId") folderId: Int): Call<ResponseBody>
@PUT("folders/{folderId}")
fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map<String, String>): Call<ResponseBody>
companion object {
const val END_POINT = "/index.php/apps/news/api/v1-2/"
}
}

View File

@ -0,0 +1,11 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.db.pojo.StarItem
data class NextNewsSyncData(
var lastModified: Long = 0,
var unreadItems: List<String> = listOf(),
var readItems: List<String> = listOf(),
var starredItems: List<StarItem> = listOf(),
var unstarredItems: List<StarItem> = listOf(),
)

View File

@ -1,8 +0,0 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.api.services.Credentials
class NextcloudNewsCredentials(login: String?, password: String?, url: String):
Credentials(if (login != null && password != null) {
okhttp3.Credentials.basic(login, password)
} else null, url)

View File

@ -1,153 +0,0 @@
package com.readrops.api.services.nextcloudnews
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.services.DataSourceResult
import com.readrops.api.services.SyncType
import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsUserAdapter
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.entities.Item
import com.readrops.db.entities.account.Account
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import okhttp3.OkHttpClient
import okhttp3.Request
class NextcloudNewsDataSource(private val service: NextcloudNewsService) {
suspend fun login(client: OkHttpClient, account: Account): String {
val request = Request.Builder()
.url(account.url + "/ocs/v1.php/cloud/users/" + account.login)
.addHeader("OCS-APIRequest", "true")
.build()
val response = client.newCall(request)
.execute()
val displayName = NextcloudNewsUserAdapter().fromXml(response.body!!.byteStream().konsumeXml())
response.close()
return displayName
}
suspend fun synchronize(syncType: SyncType, syncData: NextcloudNewsSyncData): DataSourceResult =
with(CoroutineScope(Dispatchers.IO)) {
return if (syncType == SyncType.INITIAL_SYNC) {
DataSourceResult().apply {
awaitAll(
async { folders = getFolders() },
async { feeds = getFeeds() },
async { items = getItems(ItemQueryType.ALL.value, false, MAX_ITEMS) },
async {
starredItems =
getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS)
}
)
}
} else {
awaitAll(
async { setItemsReadState(syncData) },
async { setItemsStarState(syncData) },
)
DataSourceResult().apply {
awaitAll(
async { folders = getFolders() },
async { feeds = getFeeds() },
async { items = getNewItems(syncData.lastModified, ItemQueryType.ALL) }
)
}
}
}
suspend fun getFolders() = service.getFolders()
suspend fun getFeeds() = service.getFeeds()
suspend fun getItems(type: Int, read: Boolean, batchSize: Int): List<Item> {
return service.getItems(type, read, batchSize)
}
suspend fun getNewItems(lastModified: Long, itemQueryType: ItemQueryType): List<Item> {
return service.getNewItems(lastModified, itemQueryType.value)
}
suspend fun createFeed(url: String, folderId: Int?): List<Feed> {
return service.createFeed(url, folderId)
}
suspend fun changeFeedFolder(newFolderId: Int?, feedId: Int) {
service.changeFeedFolder(feedId, mapOf("folderId" to newFolderId))
}
suspend fun renameFeed(name: String, feedId: Int) {
service.renameFeed(feedId, mapOf("feedTitle" to name))
}
suspend fun deleteFeed(feedId: Int) {
service.deleteFeed(feedId)
}
suspend fun createFolder(name: String): List<Folder> {
return service.createFolder(mapOf("name" to name))
}
suspend fun renameFolder(name: String, folderId: Int) {
service.renameFolder(folderId, mapOf("name" to name))
}
suspend fun deleteFolder(folderId: Int) {
service.deleteFolder(folderId)
}
suspend fun setItemsReadState(syncData: NextcloudNewsSyncData) = with(syncData) {
if (unreadIds.isNotEmpty()) {
service.setReadState(
StateType.UNREAD.name.lowercase(),
mapOf("itemIds" to unreadIds)
)
}
if (readIds.isNotEmpty()) {
service.setReadState(
StateType.READ.name.lowercase(),
mapOf("itemIds" to readIds)
)
}
}
suspend fun setItemsStarState(syncData: NextcloudNewsSyncData) = with(syncData) {
if (starredIds.isNotEmpty()) {
service.setStarState(
StateType.STAR.name.lowercase(),
mapOf("itemIds" to starredIds)
)
}
if (unstarredIds.isNotEmpty()) {
service.setStarState(
StateType.UNSTAR.name.lowercase(),
mapOf("itemIds" to unstarredIds)
)
}
}
enum class ItemQueryType(val value: Int) {
ALL(3),
STARRED(2)
}
enum class StateType {
READ,
UNREAD,
STAR,
UNSTAR
}
companion object {
private const val MAX_ITEMS = 5000
private const val MAX_STARRED_ITEMS = 1000
}
}

View File

@ -1,73 +0,0 @@
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 retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.PUT
import retrofit2.http.Path
import retrofit2.http.Query
interface NextcloudNewsService {
@GET("folders")
suspend fun getFolders(): List<Folder>
@GET("feeds")
suspend fun getFeeds(): List<Feed>
@GET("items")
suspend fun getItems(
@Query("type") type: Int,
@Query("getRead") read: Boolean,
@Query("batchSize") batchSize: Int
): List<Item>
@GET("items/updated")
suspend fun getNewItems(
@Query("lastModified") lastModified: Long,
@Query("type") type: Int
): List<Item>
@POST("items/{stateType}/multiple")
@JvmSuppressWildcards
suspend fun setReadState(
@Path("stateType") stateType: String,
@Body itemIdsMap: Map<String, List<Int>>
)
@POST("items/{starType}/multiple")
@JvmSuppressWildcards
suspend fun setStarState(
@Path("starType") starType: String?,
@Body body: Map<String, List<Int>>
)
@POST("feeds")
suspend fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int?): List<Feed>
@DELETE("feeds/{feedId}")
suspend fun deleteFeed(@Path("feedId") feedId: Int)
@POST("feeds/{feedId}/move")
suspend fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map<String, Int?>)
@POST("feeds/{feedId}/rename")
suspend fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map<String, String>)
@POST("folders")
suspend fun createFolder(@Body folderName: Map<String, String>): List<Folder>
@DELETE("folders/{folderId}")
suspend fun deleteFolder(@Path("folderId") folderId: Int)
@PUT("folders/{folderId}")
suspend fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map<String, String>)
companion object {
const val END_POINT = "index.php/apps/news/api/v1-3/"
}
}

View File

@ -1,9 +0,0 @@
package com.readrops.api.services.nextcloudnews
data class NextcloudNewsSyncData(
val lastModified: Long = 0,
val readIds: List<Int> = listOf(),
val unreadIds: List<Int> = listOf(),
val starredIds: List<Int> = listOf(),
val unstarredIds: List<Int> = listOf(),
)

View File

@ -11,7 +11,7 @@ import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
import java.net.URI
class NextcloudNewsFeedsAdapter {
class NextNewsFeedsAdapter {
@ToJson
fun toJson(feeds: List<Feed>): String = ""

View File

@ -8,7 +8,7 @@ import com.squareup.moshi.FromJson
import com.squareup.moshi.JsonReader
import com.squareup.moshi.ToJson
class NextcloudNewsFoldersAdapter {
class NextNewsFoldersAdapter {
@ToJson
fun toJson(folders: List<Folder>): String = ""

View File

@ -1,18 +1,18 @@
package com.readrops.api.services.nextcloudnews.adapters
import android.annotation.SuppressLint
import com.readrops.db.entities.Item
import com.readrops.api.utils.ApiUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nextNullableLong
import com.readrops.api.utils.extensions.nextNonEmptyString
import com.readrops.api.utils.extensions.nextNullableString
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.JsonReader
import com.squareup.moshi.JsonWriter
import java.time.LocalDateTime
import org.joda.time.DateTimeZone
import org.joda.time.LocalDateTime
class NextcloudNewsItemsAdapter : JsonAdapter<List<Item>>() {
class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
override fun toJson(writer: JsonWriter, value: List<Item>?) {
// no need of this
@ -40,37 +40,25 @@ class NextcloudNewsItemsAdapter : JsonAdapter<List<Item>>() {
when (reader.selectName(NAMES)) {
0 -> remoteId = reader.nextInt().toString()
1 -> link = reader.nextNullableString()
2 -> title = reader.nextNullableString()
2 -> title = reader.nextNonEmptyString()
3 -> author = reader.nextNullableString()
4 -> {
val value = reader.nextNullableLong()
pubDate = if (value != null) {
DateUtils.fromEpochSeconds(value)
} else {
LocalDateTime.now()
}
}
4 -> pubDate = LocalDateTime(reader.nextLong() * 1000L, DateTimeZone.getDefault())
5 -> content = reader.nextNullableString()
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!!)) {
if (enclosureMime != null && ApiUtils.isMimeImage(enclosureMime!!))
item.imageLink = enclosureLink
}
if (item.title != null) {
items += item
}
items += item
reader.endObject()
}
@ -84,9 +72,7 @@ class NextcloudNewsItemsAdapter : JsonAdapter<List<Item>>() {
}
companion object {
val NAMES: JsonReader.Options = JsonReader.Options.of(
"id", "url", "title", "author",
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred"
)
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title", "author",
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred", "guidHash")
}
}

View File

@ -6,7 +6,7 @@ import com.readrops.api.localfeed.XmlAdapter
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.api.utils.extensions.nonNullText
class NextcloudNewsUserAdapter : XmlAdapter<String> {
class NextNewsUserAdapter : XmlAdapter<String> {
override fun fromXml(konsumer: Konsumer): String {
var displayName: String? = null

View File

@ -1,8 +1,6 @@
package com.readrops.api.utils
import org.jsoup.Jsoup
import java.math.BigInteger
import java.security.MessageDigest
import java.util.regex.Pattern
object ApiUtils {
@ -14,14 +12,16 @@ object ApiUtils {
const val LAST_MODIFIED_HEADER = "Last-Modified"
const val IF_MODIFIED_HEADER = "If-Modified-Since"
val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml", "application/octet-stream")
const val HTTP_UNPROCESSABLE = 422
const val HTTP_NOT_FOUND = 404
const val HTTP_CONFLICT = 409
private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)"
fun isMimeImage(type: String): Boolean =
type == "image" || type == "image/jpeg" || type == "image/jpg" || type == "image/png"
fun parseContentType(header: String): String? {
fun parseContentType(header: String?): String? {
val matcher = Pattern.compile(RSS_CONTENT_TYPE_REGEX)
.matcher(header)
return if (matcher.find()) {
@ -37,14 +37,7 @@ object ApiUtils {
* @param text string to clean
* @return cleaned text
*/
fun cleanText(text: String): String {
fun cleanText(text: String?): String {
return Jsoup.parse(text).text().trim()
}
fun md5hash(value: String): String {
val bytes = MessageDigest.getInstance("MD5")
.digest(value.toByteArray())
return BigInteger(1, bytes).toString(16)
}
}

View File

@ -0,0 +1,67 @@
package com.readrops.api.utils
import org.joda.time.LocalDateTime
import org.joda.time.format.DateTimeFormat
import org.joda.time.format.DateTimeFormatterBuilder
import java.util.*
object DateUtils {
private val TAG = DateUtils::class.java.simpleName
/**
* 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 const val RSS_2_BASE_PATTERN = "EEE, dd MMM yyyy HH:mm:ss"
private const val GMT_PATTERN = "ZZZ"
private const val OFFSET_PATTERN = "Z"
private const val ISO_PATTERN = ".SSSZZ"
private const val EDT_PATTERN = "zzz"
/**
* Date pattern for format : 2019-01-04T22:21:46+00:00
*/
private const val ATOM_JSON_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
@JvmStatic
fun parse(value: String?): LocalDateTime? = if (value == null) {
null
} else try {
val formatter = DateTimeFormatterBuilder()
.appendOptional(DateTimeFormat.forPattern("$RSS_2_BASE_PATTERN ").parser) // with timezone
.appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).parser) // no timezone, important order here
.appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).parser)
.appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).parser)
.appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).parser)
.toFormatter()
.withLocale(Locale.ENGLISH)
.withOffsetParsed()
formatter.parseLocalDateTime(value)
} catch (e: Exception) {
null
}
@JvmStatic
fun formattedDateByLocal(dateTime: LocalDateTime): String {
return DateTimeFormat.mediumDate()
.withLocale(Locale.getDefault())
.print(dateTime)
}
@JvmStatic
fun formattedDateTimeByLocal(dateTime: LocalDateTime): String {
return DateTimeFormat.forPattern("dd MMM yyyy · HH:mm")
.withLocale(Locale.getDefault())
.print(dateTime)
}
}

View File

@ -4,14 +4,13 @@ import com.readrops.api.utils.exceptions.HttpException
import okhttp3.Interceptor
import okhttp3.Response
class ErrorInterceptor : Interceptor {
class ErrorInterceptor() : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
val response = chain.proceed(request)
// TODO cover all cases
if (!response.isSuccessful && response.code != 304) {
if (!response.isSuccessful) {
throw HttpException(response)
}

View File

@ -2,13 +2,10 @@ package com.readrops.api.utils
import android.nfc.FormatException
import com.readrops.api.localfeed.LocalRSSHelper
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
data class ParsingResult(
val url: String,
@ -17,111 +14,75 @@ data class ParsingResult(
object HtmlParser {
@Throws(FormatException::class)
suspend fun getFeedLink(url: String, client: OkHttpClient): List<ParsingResult> {
suspend fun getFaviconLink(url: String, client: OkHttpClient): String? {
val document = getHTMLHeadFromUrl(url, client)
val elements = document.select("link")
return document.select("link")
.filter { element ->
val type = element.attributes()["type"]
LocalRSSHelper.isRSSType(type)
}.map {
ParsingResult(
url = it.absUrl("href"),
label = it.attributes()["title"]
for (element in elements) {
if (element.attributes()["rel"].lowercase().contains("icon")) {
return element.absUrl("href")
}
}
return null
}
suspend fun getFeedLink(url: String, client: OkHttpClient): List<ParsingResult> {
val results = mutableListOf<ParsingResult>()
val document = getHTMLHeadFromUrl(url, client)
val elements = document.select("link")
for (element in elements) {
val type = element.attributes()["type"]
if (LocalRSSHelper.isRSSType(type)) {
results += ParsingResult(
url = element.absUrl("href"),
label = element.attributes()["title"]
)
}
}
fun getFaviconLink(document: Document): String? {
val links = document.select("link")
.filter { element -> element.attributes()["rel"].contains("icon") }
.sortedWith(compareByDescending<Element> {
it.attributes()["rel"] == "apple-touch-icon"
}.thenByDescending { element ->
val sizes = element.attr("sizes")
if (sizes.isNotEmpty()) {
try {
sizes.filter { it.isDigit() }
.toInt()
} catch (e: Exception) {
0
}
} else {
0
}
})
return links.firstOrNull()
?.absUrl("href")
}
fun getFeedImage(document: Document): String? {
return document.select("meta")
.firstOrNull { element ->
val property = element.attr("property")
listOf("og:image", "twitter:image").any { it == property }
}
?.absUrl("content")
}
fun getFeedDescription(document: Document): String? {
return document.select("meta")
.firstOrNull { element ->
val property = element.attr("property")
listOf("og:title", "twitter:title").any { it == property }
}
?.attr("content")
}
suspend fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document =
withContext(Dispatchers.IO) {
client.newCall(
Request.Builder()
.url(url)
.build()
).execute()
.use { response ->
if (response.header(ApiUtils.CONTENT_TYPE_HEADER)!!
.contains(ApiUtils.HTML_CONTENT_TYPE)
) {
val body = response.body!!.source()
val stringBuilder = StringBuilder()
var collectionStarted = false
while (!body.exhausted()) {
val currentLine = body.readUtf8LineStrict()
when {
currentLine.contains("<head>") -> {
stringBuilder.append(currentLine)
collectionStarted = true
}
currentLine.contains("</head>") -> {
stringBuilder.append(currentLine)
break
}
collectionStarted -> {
stringBuilder.append(currentLine)
}
}
}
if (!stringBuilder.contains("<head>") || !stringBuilder.contains("</head>")) {
body.close()
throw FormatException("Failed to get HTML head from $url")
}
body.close()
Jsoup.parse(stringBuilder.toString(), url)
} else {
response.close()
throw FormatException("Response from $url is not a html file")
}
}
}
return results
}
private fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document {
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (response.header(ApiUtils.CONTENT_TYPE_HEADER)!!.contains(ApiUtils.HTML_CONTENT_TYPE)
) {
val body = response.body!!.source()
val stringBuilder = StringBuilder()
var collectionStarted = false
while (!body.exhausted()) {
val currentLine = body.readUtf8LineStrict()
when {
currentLine.contains("<head>") -> {
stringBuilder.append(currentLine)
collectionStarted = true
}
currentLine.contains("</head>") -> {
stringBuilder.append(currentLine)
break
}
collectionStarted -> {
stringBuilder.append(currentLine)
}
}
}
if (!stringBuilder.contains("<head>") || !stringBuilder.contains("</head>"))
throw FormatException("Failed to get HTML head")
body.close()
return Jsoup.parse(stringBuilder.toString(), url)
} else {
throw FormatException("The response is not a html file")
}
}
}
}

View File

@ -1,10 +1,9 @@
package com.readrops.api.utils.exceptions
import okhttp3.Response
import java.io.IOException
class HttpException(val response: Response) : IOException() {
class HttpException(val response: Response) : Exception() {
val code: Int
get() = response.code

View File

@ -1,3 +0,0 @@
package com.readrops.api.utils.exceptions
class LoginFailedException(override val message: String? = null) : Exception()

View File

@ -13,19 +13,3 @@ fun JsonReader.nextNonEmptyString(): String {
fun JsonReader.nextNullableInt(): Int? =
if (peek() != JsonReader.Token.NULL) nextInt() else nextNull()
fun JsonReader.nextNullableLong(): Long? =
if (peek() != JsonReader.Token.NULL) nextLong() else nextNull()
fun JsonReader.skipField() {
skipName()
skipValue()
}
fun JsonReader.skipToEnd() {
while (hasNext()) {
skipField()
}
}
fun Int.toBoolean(): Boolean = this == 1

View File

@ -19,7 +19,7 @@ fun Konsumer.nullableText(): String? {
}
fun Konsumer.nullableTextRecursively(): String? {
val text = textRecursively(whitespace = Whitespace.preserve)
val text = textRecursively()
return if (text.isNotEmpty()) text.trim() else null
}

View File

@ -1,25 +0,0 @@
package com.readrops.api
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import java.io.InputStream
import java.net.HttpURLConnection
fun MockWebServer.enqueueOK() {
enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
)
}
fun MockWebServer.enqueueOKStream(stream: InputStream) {
enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
}
fun MockResponse.Companion.okResponseWithBody(stream: InputStream): MockResponse {
return MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream))
}

View File

@ -2,10 +2,9 @@ package com.readrops.api.localfeed
import com.gitlab.mvysny.konsumexml.Names
import com.gitlab.mvysny.konsumexml.konsumeXml
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import junit.framework.TestCase.*
import org.junit.Test
import java.io.ByteArrayInputStream
class LocalRSSHelperTest {
@ -17,6 +16,8 @@ class LocalRSSHelperTest {
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)
}

View File

@ -3,12 +3,17 @@ package com.readrops.api.localfeed
import com.readrops.api.localfeed.atom.ATOMFeedAdapter
import com.readrops.api.localfeed.rss1.RSS1FeedAdapter
import com.readrops.api.localfeed.rss2.RSS2FeedAdapter
import junit.framework.Assert.assertTrue
import org.junit.Assert.assertThrows
import org.junit.Assert.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)

View File

@ -2,12 +2,14 @@ package com.readrops.api.localfeed.atom
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.TestUtils
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.exceptions.ParseException
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import java.lang.Exception
class ATOMAdapterTest {
@ -26,17 +28,16 @@ class ATOMAdapterTest {
assertEquals(url, "https://github.com/readrops/Readrops/commits/develop.atom")
assertEquals(siteUrl, "https://github.com/readrops/Readrops/commits/develop")
assertEquals(description, "Here is a subtitle")
assertEquals(imageUrl, "https://github.com/readrops/Readrops/blob/develop/images/readrops_logo.png")
}
with(items.first()) {
with(items[0]) {
assertEquals(items.size, 4)
assertEquals(title, "Add an option to open item url in custom tab")
assertEquals(link, "https://github.com/readrops/Readrops/commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
assertEquals(pubDate!!.year, 2019)
assertEquals(pubDate, DateUtils.parse("2020-09-06T21:09:59Z"))
assertEquals(author, "Shinokuni")
assertEquals(description, "Summary")
assertEquals(remoteId, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
assertEquals(guid, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
TestCase.assertNotNull(content)
}
}
@ -70,15 +71,4 @@ class ATOMAdapterTest {
assertTrue(exception.message!!.contains("Item link is required"))
}
@Test
fun mediaGroupTest() {
val stream = TestUtils.loadResource("localfeed/atom/atom_item_media_group.xml")
val pair = adapter.fromXml(stream.konsumeXml())
with(pair.second.first()) {
assertEquals("description", text)
assertEquals("https://i3.ytimg.com/vi/.../hqdefault.jpg", imageLink)
}
}
}

View File

@ -1,14 +1,15 @@
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.Feed
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
import okio.Buffer
import org.junit.Assert.assertThrows
import org.junit.Test
@ -35,12 +36,11 @@ class JSONFeedAdapterTest {
assertEquals(url, "http://flyingmeat.com/blog/feed.json")
assertEquals(siteUrl, "http://flyingmeat.com/blog/")
assertEquals(description, "News from your friends at Flying Meat.")
assertEquals(imageUrl, "https://secure.flyingmeat.com/favicon.ico")
}
with(items[0]) {
assertEquals(items.size, 10)
assertEquals(remoteId, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
assertEquals(guid, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
assertEquals(title, "Acorn and 10.13")
assertEquals(link, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
assertEquals(pubDate, DateUtils.parse("2017-09-25T14:27:27-07:00"))

View File

@ -2,11 +2,11 @@ package com.readrops.api.localfeed.rss1
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.TestUtils
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.util.DateUtils
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertNotNull
import junit.framework.TestCase
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
@ -28,7 +28,6 @@ class RSS1AdapterTest {
assertEquals(url, "https://slashdot.org/")
assertEquals(siteUrl, "https://slashdot.org/")
assertEquals(description, "News for nerds, stuff that matters")
assertEquals(imageUrl, "https://a.fsdn.com/sd/topics/topicslashdot.gif")
}
with(items[0]) {
@ -36,7 +35,7 @@ class RSS1AdapterTest {
assertEquals(title, "Google Expands its Flutter Development Kit To Windows Apps")
assertEquals(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(remoteId!!.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" +
assertEquals(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(pubDate, DateUtils.parse("2020-09-23T16:15:00+00:00"))
assertEquals(author, "msmash")

View File

@ -2,8 +2,8 @@ package com.readrops.api.localfeed.rss2
import com.gitlab.mvysny.konsumexml.konsumeXml
import com.readrops.api.TestUtils
import com.readrops.api.utils.DateUtils
import com.readrops.api.utils.exceptions.ParseException
import com.readrops.db.util.DateUtils
import junit.framework.TestCase
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertTrue
@ -27,7 +27,6 @@ class RSS2AdapterTest {
assertEquals(url, "https://news.ycombinator.com/feed/")
assertEquals(siteUrl, "https://news.ycombinator.com/")
assertEquals(description, "Links for the intellectually curious, ranked by readers.")
assertEquals(imageUrl, "https://news.ycombinator.com/y18.svg")
}
with(items[0]) {
@ -37,7 +36,7 @@ class RSS2AdapterTest {
assertEquals(pubDate, DateUtils.parse("Tue, 25 Aug 2020 17:15:49 +0000"))
assertEquals(author, "Author 1")
assertEquals(description, "<a href=\"https://news.ycombinator.com/item?id=24273602\">Comments</a>")
assertEquals(remoteId, "https://www.bbc.com/news/world-africa-53887947")
assertEquals(guid, "https://www.bbc.com/news/world-africa-53887947")
}
}
@ -56,7 +55,7 @@ class RSS2AdapterTest {
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_other_namespaces.xml")
val item = adapter.fromXml(stream.konsumeXml()).second[0]
assertEquals(item.remoteId, "guid")
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")
@ -95,9 +94,9 @@ class RSS2AdapterTest {
@Test
fun enclosureTest() {
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_enclosure.xml")
val item = adapter.fromXml(stream.konsumeXml()).second.first()
val item = adapter.fromXml(stream.konsumeXml()).second[0]
assertEquals("https://image1.jpg", item.imageLink)
assertEquals(item.imageLink, "https://image1.jpg")
}
@Test
@ -112,8 +111,8 @@ class RSS2AdapterTest {
@Test
fun mediaGroupTest() {
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_media_group.xml")
val item = adapter.fromXml(stream.konsumeXml()).second.first()
val item = adapter.fromXml(stream.konsumeXml()).second[0]
assertEquals("https://image1.jpg", item.imageLink)
assertEquals(item.imageLink, "https://image1.jpg")
}
}

View File

@ -4,8 +4,8 @@ import com.readrops.api.TestUtils
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 kotlinx.coroutines.test.runTest
import org.junit.Test
import java.io.File
import java.io.FileOutputStream
@ -13,87 +13,84 @@ import java.io.FileOutputStream
class OPMLParserTest {
@Test
fun readOpmlTest() = runTest {
fun readOpmlTest() {
val stream = TestUtils.loadResource("opml/subscriptions.opml")
val foldersAndFeeds = OPMLParser.read(stream)
assertEquals(foldersAndFeeds.size, 6)
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
assertEquals(foldersAndFeeds[Folder(name = "Folder 1")]?.size, 2)
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 1")]?.size, 4)
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 2")]?.size, 1)
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 1")]?.size, 2)
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 2")]?.size, 0)
assertEquals(foldersAndFeeds[null]?.size, 2)
OPMLParser.read(stream)
.observeOn(Schedulers.trampoline())
.subscribeOn(Schedulers.trampoline())
.subscribe { result -> foldersAndFeeds = result }
assertEquals(foldersAndFeeds?.size, 6)
assertEquals(foldersAndFeeds?.get(Folder(name = "Folder 1"))?.size, 2)
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 1"))?.size, 4)
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 2"))?.size, 1)
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 1"))?.size, 2)
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 2"))?.size, 0)
assertEquals(foldersAndFeeds?.get(null)?.size, 2)
stream.close()
}
@Test
fun readLiteSubscriptionsTest() = runTest {
fun readLiteSubscriptionsTest() {
val stream = TestUtils.loadResource("opml/lite_subscriptions.opml")
val foldersAndFeeds = OPMLParser.read(stream)
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
assertEquals(foldersAndFeeds.values.first().size, 2)
assertEquals(
foldersAndFeeds.values.first().first().url,
"http://www.theverge.com/rss/index.xml"
)
assertEquals(foldersAndFeeds.values.first()[1].url, "https://techcrunch.com/feed/")
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(expected = ParseException::class)
fun opmlVersionTest() = runTest {
@Test
fun opmlVersionTest() {
val stream = TestUtils.loadResource("opml/wrong_version.opml")
OPMLParser.read(stream)
.test()
.assertError(ParseException::class.java)
stream.close()
}
@Test
fun writeOpmlTest() = runTest {
fun writeOpmlTest() {
val file = File("subscriptions.opml")
val outputStream = FileOutputStream(file)
val foldersAndFeeds: Map<Folder?, List<Feed>> = HashMap<Folder?, List<Feed>>().apply {
put(
null, listOf(
Feed(name = "Feed1", url = "https://feed1.com"),
Feed(name = "Feed2", url = "https://feed2.com")
)
)
put(null, listOf(Feed(name = "Feed1", url = "https://feed1.com"),
Feed(name = "Feed2", url = "https://feed2.com")))
put(Folder(name = "Folder1"), listOf())
put(
Folder(name = "Folder2"), listOf(
Feed(name = "Feed3", url = "https://feed3.com"),
Feed(name = "Feed4", url = "https://feed4.com")
)
)
put(Folder(name = "Folder2"), listOf(Feed(name = "Feed3", url = "https://feed3.com"),
Feed(name = "Feed4", url ="https://feed4.com")))
}
OPMLParser.write(foldersAndFeeds, outputStream)
.subscribeOn(Schedulers.trampoline())
.subscribe()
outputStream.flush()
outputStream.close()
val inputStream = file.inputStream()
val foldersAndFeeds2 = OPMLParser.read(inputStream)
var foldersAndFeeds2: Map<Folder?, List<Feed>>? = null
OPMLParser.read(inputStream).subscribe { result -> foldersAndFeeds2 = result }
assertEquals(foldersAndFeeds.size, foldersAndFeeds2.size)
assertEquals(
foldersAndFeeds[Folder(name = "Folder1")]?.size,
foldersAndFeeds2[Folder(name = "Folder1")]?.size
)
assertEquals(
foldersAndFeeds[Folder(name = "Folder2")]?.size,
foldersAndFeeds2[Folder(name = "Folder2")]?.size
)
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2[null]?.size)
assertEquals(foldersAndFeeds.size, foldersAndFeeds2?.size)
assertEquals(foldersAndFeeds[Folder(name = "Folder1")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder1"))?.size)
assertEquals(foldersAndFeeds[Folder(name = "Folder2")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder2"))?.size)
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2?.get(null)?.size)
inputStream.close()
file.delete()
}
}

View File

@ -1,7 +1,7 @@
package com.readrops.api.services
import com.readrops.api.services.greader.GReaderCredentials
import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials
import com.readrops.api.services.freshrss.FreshRSSCredentials
import com.readrops.api.services.nextcloudnews.NextNewsCredentials
import org.junit.Test
import kotlin.test.assertEquals
@ -9,7 +9,7 @@ class CredentialsTest {
@Test
fun credentialsTest() {
val credentials = GReaderCredentials("token", "https://freshrss.org")
val credentials = FreshRSSCredentials("token", "https://freshrss.org")
assertEquals(credentials.authorization!!, "GoogleLogin auth=token")
assertEquals(credentials.url, "https://freshrss.org")
@ -17,7 +17,7 @@ class CredentialsTest {
@Test
fun nextcloudNewsCredentialsTest() {
val credentials = NextcloudNewsCredentials("login", "password", "https://freshrss.org")
val credentials = NextNewsCredentials("login", "password", "https://freshrss.org")
assertEquals(credentials.authorization!!, "Basic bG9naW46cGFzc3dvcmQ=")
assertEquals(credentials.url, "https://freshrss.org")

View File

@ -1,241 +0,0 @@
package com.readrops.api.services.fever
import com.readrops.api.TestUtils
import com.readrops.api.apiModule
import com.readrops.api.enqueueOK
import com.readrops.api.enqueueOKStream
import com.readrops.api.okResponseWithBody
import com.readrops.api.services.SyncType
import com.readrops.api.utils.AuthInterceptor
import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.koin.core.parameter.parametersOf
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.test.get
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FeverDataSourceTest : KoinTest {
private lateinit var dataSource: FeverDataSource
private val mockServer = MockWebServer()
@Before
fun before() {
mockServer.start(8080)
val url = mockServer.url("")
dataSource = get(parameters = {
parametersOf(FeverCredentials(null, null, url.toString()))
})
}
@After
fun tearDown() {
mockServer.close()
}
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(apiModule, module {
single {
OkHttpClient.Builder()
.callTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.HOURS)
.addInterceptor(get<AuthInterceptor>())
.build()
}
})
}
@Test
fun loginSuccessfulTest() = runTest {
val stream = TestUtils.loadResource("services/fever/successful_auth.json")
mockServer.enqueueOKStream(stream)
assertTrue { dataSource.login("", "") }
}
@Test
fun loginFailedTest() = runTest {
val stream = TestUtils.loadResource("services/fever/failed_auth.json")
mockServer.enqueueOKStream(stream)
assertFalse { dataSource.login("", "") }
}
@Test
fun setItemStateTest() = runTest {
mockServer.enqueueOK()
dataSource.setItemState("login", "password", "saved", "itemId")
val request = mockServer.takeRequest()
val requestBody = request.body.readUtf8()
assertEquals("saved", request.requestUrl?.queryParameter("as"))
assertEquals("itemId", request.requestUrl?.queryParameter("id"))
assertTrue { requestBody.contains("api_key") }
assertTrue { requestBody.contains("fb2f5a9b0eccc1ee95c1d559a2dd797a") }
}
@Test
fun initialSyncTest() = runTest {
var pageNumber = 0
var firstMaxId = ""
var secondMaxId = ""
var thirdMaxId = ""
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
return when {
this == "/?feeds" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/feeds.json"))
}
this == "/?groups" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/folders.json"))
}
this == "/?favicons" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/favicons.json"))
}
this == "/?unread_item_ids" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/itemsIds.json"))
}
this == "/?saved_item_ids" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/itemsIds.json"))
}
contains("/?items") -> {
when (pageNumber++) {
0 -> {
firstMaxId = request.requestUrl?.queryParameter("max_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/items_page2.json"))
}
1 -> {
secondMaxId = request.requestUrl?.queryParameter("max_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/items_page1.json"))
}
2 -> {
thirdMaxId = request.requestUrl?.queryParameter("max_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/empty_items.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result = dataSource.synchronize("login", "password", SyncType.INITIAL_SYNC, "")
assertEquals(1, result.folders.size)
assertEquals(1, result.feverFeeds.feeds.size)
assertEquals(3, result.favicons.size)
assertEquals(6, result.unreadIds.size)
assertEquals(6, result.starredIds.size)
assertEquals(10, result.items.size)
assertEquals(10, result.items.size)
assertEquals(1564058340320135, result.sinceId)
assertEquals("1564058340320135", firstMaxId)
assertEquals("6", secondMaxId)
assertEquals("1", thirdMaxId)
}
@Test
fun classicSyncTest() = runTest {
var pageNumber = 0
var firstLastSinceId = ""
var secondLastSinceId = ""
var thirdLastSinceId = ""
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
return when {
this == "/?feeds" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/feeds.json"))
}
this == "/?groups" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/folders.json"))
}
this == "/?favicons" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/favicons.json"))
}
this == "/?unread_item_ids" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/itemsIds.json"))
}
this == "/?saved_item_ids" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/itemsIds.json"))
}
contains("/?items") -> {
when (pageNumber++) {
0 -> {
firstLastSinceId = request.requestUrl?.queryParameter("since_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/items_page1.json"))
}
1 -> {
secondLastSinceId = request.requestUrl?.queryParameter("since_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/items_page2.json"))
}
2 -> {
thirdLastSinceId = request.requestUrl?.queryParameter("since_id").orEmpty()
MockResponse.okResponseWithBody(TestUtils.loadResource("services/fever/empty_items.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result = dataSource.synchronize("login", "password", SyncType.CLASSIC_SYNC, "1")
assertEquals(1, result.folders.size)
assertEquals(1, result.feverFeeds.feeds.size)
assertEquals(3, result.favicons.size)
assertEquals(6, result.unreadIds.size)
assertEquals(6, result.starredIds.size)
assertEquals(10, result.items.size)
assertEquals(10, result.sinceId)
assertEquals("1", firstLastSinceId)
assertEquals("5", secondLastSinceId)
assertEquals("10", thirdLastSinceId)
mockServer.dispatcher.shutdown()
}
}

View File

@ -1,30 +0,0 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
import okio.Buffer
import org.junit.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class FeverAPIAdapterTest {
private val adapter = Moshi.Builder()
.add(Boolean::class.java, FeverAPIAdapter())
.build()
.adapter(Boolean::class.java)
@Test
fun authenticatedTest() {
val stream = TestUtils.loadResource("services/fever/successful_auth.json")
assertTrue { adapter.fromJson(Buffer().readFrom(stream))!! }
}
@Test
fun unauthenticatedTest() {
val stream = TestUtils.loadResource("services/fever/unsuccessful_auth.json")
assertFalse { adapter.fromJson(Buffer().readFrom(stream))!! }
}
}

View File

@ -1,32 +0,0 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
class FeverFaviconsAdapterTest {
private val adapter = Moshi.Builder()
.add(FeverFaviconsAdapter())
.build()
.adapter<List<Favicon>>(Types.newParameterizedType(List::class.java, Favicon::class.java))
@Test
fun validFaviconsTest() {
val stream = TestUtils.loadResource("services/fever/favicons.json")
val favicons = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(favicons.size, 3)
with(favicons.first()) {
assertEquals(id, 85)
assertNotNull(data)
}
}
}

View File

@ -1,41 +0,0 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
class FeverFeedsAdapterTest {
val adapter = Moshi.Builder()
.add(FeverFeeds::class.java, FeverFeedsAdapter())
.build()
.adapter(FeverFeeds::class.java)!!
@Test
fun validFeedsTest() {
val stream = TestUtils.loadResource("services/fever/feeds.json")
val feverFeeds = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(feverFeeds.feeds.size, 1)
with(feverFeeds.feeds.first()) {
assertEquals(name, "xda-developers")
assertEquals(url, "https://www.xda-developers.com/feed/")
assertEquals(siteUrl, "https://www.xda-developers.com/")
assertEquals(remoteId, "32")
}
with(feverFeeds.feedsGroups.entries.first()) {
assertEquals(key, 3)
assertEquals(value, listOf(5, 4))
}
with(feverFeeds.favicons.entries.first()) {
assertEquals(30, key)
assertEquals("32", value)
}
}
}

View File

@ -1,31 +0,0 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Folder
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
class FeverFoldersAdapterTest {
private val adapter = Moshi.Builder()
.add(FeverFoldersAdapter())
.build()
.adapter<List<Folder>>(Types.newParameterizedType(List::class.java, Folder::class.java))
@Test
fun validFoldersTest() {
val stream = TestUtils.loadResource("services/fever/folders.json")
val folders = adapter.fromJson(Buffer().readFrom(stream))!!
with(folders.first()) {
assertEquals(name, "Libre")
assertEquals(remoteId, "4")
}
}
}

View File

@ -1,38 +0,0 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Item
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class FeverItemsAdapterTest {
private val adapter = Moshi.Builder()
.add(FeverItemsAdapter())
.build()
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
@Test
fun validItemsTest() {
val stream = TestUtils.loadResource("services/fever/items_page2.json")
val items = adapter.fromJson(Buffer().readFrom(stream))!!
with(items.first()) {
assertEquals(title, "FreshRSS 1.9.0")
assertEquals(author, "Alkarex")
assertEquals(link, "https://github.com/FreshRSS/FreshRSS/releases/tag/1.9.0")
assertNotNull(content)
assertTrue(isStarred)
assertTrue(isRead)
assertNotNull(pubDate)
assertEquals(remoteId, "6")
assertEquals(feedRemoteId, "2")
}
}
}

View File

@ -1,25 +0,0 @@
package com.readrops.api.services.fever.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import okio.Buffer
import org.junit.Test
import kotlin.test.assertEquals
class FeverItemsIdsAdapterTest {
private val adapter = Moshi.Builder()
.add(FeverItemsIdsAdapter())
.build()
.adapter<List<String>>(Types.newParameterizedType(List::class.java, String::class.java))
@Test
fun validIdsTest() {
val stream = TestUtils.loadResource("services/fever/itemsIds.json")
val ids = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(ids.size, 6)
}
}

View File

@ -0,0 +1,260 @@
package com.readrops.api.services.freshrss
import com.readrops.api.TestUtils
import com.readrops.api.apiModule
import kotlinx.coroutines.runBlocking
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.core.qualifier.named
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.test.get
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.net.HttpURLConnection
import java.net.URLEncoder
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FreshRSSDataSourceTest : KoinTest {
private lateinit var freshRSSDataSource: NewFreshRSSDataSource
private val mockServer = MockWebServer()
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(apiModule, module {
single {
Retrofit.Builder()
.baseUrl("http://localhost:8080/")
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
.build()
.create(NewFreshRSSService::class.java)
}
})
}
@Before
fun before() {
mockServer.start(8080)
freshRSSDataSource = NewFreshRSSDataSource(get())
}
@After
fun tearDown() {
mockServer.shutdown()
}
@Test
fun loginTest() {
runBlocking {
val responseBody = TestUtils.loadResource("services/freshrss/login_response_body")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(responseBody)))
val authString = freshRSSDataSource.login("Login", "Password")
assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString)
val request = mockServer.takeRequest()
val requestBody = request.body.readUtf8()
assertTrue {
requestBody.contains("name=\"Email\"") && requestBody.contains("Login")
}
assertTrue {
requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password")
}
}
}
@Test
fun writeTokenTest() = runBlocking {
val responseBody = TestUtils.loadResource("services/freshrss/writetoken_response_body")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(responseBody)))
val writeToken = freshRSSDataSource.getWriteToken()
assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken)
}
@Test
fun userInfoTest() = runBlocking {
}
@Test
fun foldersTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val folders = freshRSSDataSource.getFolders()
assertTrue { folders.size == 1 }
}
@Test
fun feedsTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val feeds = freshRSSDataSource.getFeeds()
assertTrue { feeds.size == 1 }
}
@Test
fun itemsTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val items = freshRSSDataSource.getItems(listOf(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_STARRED), 100, 21343321321321)
assertTrue { items.size == 2 }
val request = mockServer.takeRequest()
with(request.requestUrl!!) {
assertEquals(listOf(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_STARRED), queryParameterValues("xt"))
assertEquals("100", queryParameter("n"))
assertEquals("21343321321321", queryParameter("ot"))
}
}
@Test
fun starredItemsTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val items = freshRSSDataSource.getStarredItems(100)
assertTrue { items.size == 2 }
val request = mockServer.takeRequest()
assertEquals("100", request.requestUrl!!.queryParameter("n"))
}
@Test
fun getItemsIdsTest() = runBlocking {
val stream = TestUtils.loadResource("services/freshrss/adapters/items_starred_ids.json")
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.setBody(Buffer().readFrom(stream)))
val ids = freshRSSDataSource.getItemsIds(NewFreshRSSDataSource.GOOGLE_READ, NewFreshRSSDataSource.GOOGLE_READING_LIST, 100)
assertTrue { ids.size == 5 }
val request = mockServer.takeRequest()
with(request.requestUrl!!) {
assertEquals(NewFreshRSSDataSource.GOOGLE_READ, queryParameter("xt"))
assertEquals(NewFreshRSSDataSource.GOOGLE_READING_LIST, queryParameter("s"))
assertEquals("100", queryParameter("n"))
}
}
@Test
fun createFeedTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.createFeed("token", "https://feed.url")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
assertTrue { contains("ac=subscribe") }
}
}
@Test
fun deleteFeedTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.deleteFeed("token", "https://feed.url")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
assertTrue { contains("ac=unsubscribe") }
}
}
@Test
fun updateFeedTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=${URLEncoder.encode("${NewFreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
assertTrue { contains("t=title") }
assertTrue { contains("a=folderId") }
assertTrue { contains("ac=edit") }
}
}
@Test
fun createFolderTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.createFolder("token", "folder")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("a=${URLEncoder.encode("${NewFreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") }
}
}
@Test
fun updateFolderTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.updateFolder("token", "folderId", "folder")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=folderId") }
assertTrue { contains("dest=${URLEncoder.encode("${NewFreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") }
}
}
@Test
fun deleteFolderTest() = runBlocking {
mockServer.enqueue(MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK))
freshRSSDataSource.deleteFolder("token", "folderId")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=folderId") }
}
}
}

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.greader.adapters
package com.readrops.api.services.freshrss.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Feed
@ -8,18 +8,18 @@ import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.junit.Test
class GReaderFeedsAdapterTest {
class FreshRSSFeedsAdapterTest {
private val adapter = Moshi.Builder()
.add(GReaderFeedsAdapter())
.add(FreshRSSFeedsAdapter())
.build()
.adapter<List<Feed>>(Types.newParameterizedType(List::class.java, Feed::class.java))
@Test
fun validFeedsTest() {
val stream = TestUtils.loadResource("services/greader/adapters/feeds.json")
val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json")
val feed = adapter.fromJson(Buffer().readFrom(stream))!!.first()
val feed = adapter.fromJson(Buffer().readFrom(stream))!![0]
with(feed) {
assertEquals(remoteId, "feed/2")

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.greader.adapters
package com.readrops.api.services.freshrss.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Folder
@ -8,22 +8,22 @@ import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.junit.Test
class GReaderFoldersAdapterTest {
class FreshRSSFoldersAdapterTest {
private val adapter = Moshi.Builder()
.add(GReaderFoldersAdapter())
.add(FreshRSSFoldersAdapter())
.build()
.adapter<List<Folder>>(Types.newParameterizedType(List::class.java, Folder::class.java))
@Test
fun validFoldersTest() {
val stream = TestUtils.loadResource("services/greader/adapters/folders.json")
val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json")
val folders = adapter.fromJson(Buffer().readFrom(stream))!!
assertEquals(folders.size, 1)
with(folders.first()) {
with(folders[0]) {
assertEquals(name, "Blogs")
assertEquals(remoteId, "user/-/label/Blogs")
}

View File

@ -1,35 +1,35 @@
package com.readrops.api.services.greader.adapters
package com.readrops.api.services.freshrss.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
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.joda.time.LocalDateTime
import org.junit.Test
class GReaderItemsAdapterTest {
class FreshRSSItemsAdapterTest {
private val adapter = Moshi.Builder()
.add(Types.newParameterizedType(List::class.java, Item::class.java), GReaderItemsAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), FreshRSSItemsAdapter())
.build()
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
@Test
fun validItemsTest() {
val stream = TestUtils.loadResource("services/greader/adapters/items.json")
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
val items = adapter.fromJson(Buffer().readFrom(stream))!!
with(items.first()) {
with(items[0]) {
assertEquals(remoteId, "tag:google.com,2005:reader/item/0005c62466ee28fe")
assertEquals(title, "GNOMEs Default Theme is Getting a Revamp")
assertNotNull(content)
assertEquals(link, "http://feedproxy.google.com/~r/d0od/~3/4Zk-fncSuek/adwaita-borderless-theme-in-development-gnome-41")
assertEquals(author, "Joey Sneddon")
assertEquals(pubDate, DateUtils.fromEpochSeconds(1625234040))
assertEquals(pubDate, LocalDateTime(1625234040 * 1000L))
assertEquals(isRead, false)
assertEquals(isStarred, false)
}

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.greader.adapters
package com.readrops.api.services.freshrss.adapters
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
@ -6,16 +6,16 @@ import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.junit.Test
class GReaderItemsIdsAdapterTest {
class FreshRSSItemsIdsAdapterTest {
private val adapter = Moshi.Builder()
.add(Types.newParameterizedType(List::class.java, String::class.java), GReaderItemsIdsAdapter())
.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/greader/adapters/items_starred_ids.json")
val stream = javaClass.classLoader!!.getResourceAsStream("services/freshrss/adapters/items_starred_ids.json")
val ids = adapter.fromJson(Buffer().readFrom(stream))!!

View File

@ -1,4 +1,4 @@
package com.readrops.api.services.greader.adapters
package com.readrops.api.services.freshrss.adapters
import com.readrops.api.TestUtils
import com.squareup.moshi.Moshi
@ -6,7 +6,7 @@ import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.junit.Test
class GReaderUserInfoAdapterTest {
class FreshRSSUserInfoAdapterTest {
private val adapter = Moshi.Builder()
.add(FreshRSSUserInfoAdapter())
@ -15,7 +15,7 @@ class GReaderUserInfoAdapterTest {
@Test
fun userInfoTest() {
val stream = TestUtils.loadResource("services/greader/adapters/user_info.json")
val stream = TestUtils.loadResource("services/freshrss/adapters/user_info.json")
val userInfo = adapter.fromJson(Buffer().readFrom(stream))!!

View File

@ -1,421 +0,0 @@
package com.readrops.api.services.greader
import com.readrops.api.TestUtils
import com.readrops.api.apiModule
import com.readrops.api.enqueueOK
import com.readrops.api.enqueueOKStream
import com.readrops.api.okResponseWithBody
import com.readrops.api.services.SyncType
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.test.get
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.net.URLEncoder
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class GReaderDataSourceTest : KoinTest {
private lateinit var freshRSSDataSource: GReaderDataSource
private val mockServer = MockWebServer()
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(apiModule, module {
single {
Retrofit.Builder()
.baseUrl("http://localhost:8080/")
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("greaderMoshi"))))
.build()
.create(GReaderService::class.java)
}
})
}
@Before
fun before() {
mockServer.start(8080)
freshRSSDataSource = GReaderDataSource(get())
}
@After
fun tearDown() {
mockServer.shutdown()
}
@Test
fun loginTest() = runTest {
val responseBody = TestUtils.loadResource("services/greader/login_response_body")
mockServer.enqueueOKStream(responseBody)
val authString = freshRSSDataSource.login("Login", "Password")
assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString)
val request = mockServer.takeRequest()
val requestBody = request.body.readUtf8()
assertTrue {
requestBody.contains("name=\"Email\"") && requestBody.contains("Login")
}
assertTrue {
requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password")
}
}
@Test
fun writeTokenTest() = runTest {
val responseBody = TestUtils.loadResource("services/greader/writetoken_response_body")
mockServer.enqueueOKStream(responseBody)
val writeToken = freshRSSDataSource.getWriteToken()
assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken)
}
@Test
fun userInfoTest() = runTest {
val responseBody = TestUtils.loadResource("services/greader/adapters/user_info.json")
mockServer.enqueueOKStream(responseBody)
val userInfo = freshRSSDataSource.getUserInfo()
assertEquals("test", userInfo.userName)
}
@Test
fun foldersTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/folders.json")
mockServer.enqueueOKStream(stream)
val folders = freshRSSDataSource.getFolders()
assertTrue { folders.size == 1 }
}
@Test
fun feedsTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/feeds.json")
mockServer.enqueueOKStream(stream)
val feeds = freshRSSDataSource.getFeeds()
assertTrue { feeds.size == 1 }
}
@Test
fun itemsTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/items.json")
mockServer.enqueueOKStream(stream)
val items = freshRSSDataSource.getItems(
excludeTargets = listOf(
GReaderDataSource.GOOGLE_READ,
GReaderDataSource.GOOGLE_STARRED
),
max = 100,
lastModified = 21343321321321
)
assertTrue { items.size == 2 }
val request = mockServer.takeRequest()
with(request.requestUrl!!) {
assertEquals(
listOf(GReaderDataSource.GOOGLE_READ, GReaderDataSource.GOOGLE_STARRED),
queryParameterValues("xt")
)
assertEquals("100", queryParameter("n"))
assertEquals("21343321321321", queryParameter("ot"))
}
}
@Test
fun starredItemsTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/items.json")
mockServer.enqueueOKStream(stream)
val items = freshRSSDataSource.getStarredItems(100)
assertTrue { items.size == 2 }
val request = mockServer.takeRequest()
assertEquals("100", request.requestUrl!!.queryParameter("n"))
}
@Test
fun getItemsIdsTest() = runTest {
val stream = TestUtils.loadResource("services/greader/adapters/items_starred_ids.json")
mockServer.enqueueOKStream(stream)
val ids = freshRSSDataSource.getItemsIds(
excludeTarget = GReaderDataSource.GOOGLE_READ,
includeTarget = GReaderDataSource.GOOGLE_READING_LIST,
max = 100
)
assertTrue { ids.size == 5 }
val request = mockServer.takeRequest()
with(request.requestUrl!!) {
assertEquals(GReaderDataSource.GOOGLE_READ, queryParameter("xt"))
assertEquals(GReaderDataSource.GOOGLE_READING_LIST, queryParameter("s"))
assertEquals("100", queryParameter("n"))
}
}
@Test
fun createFeedTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.createFeed("token", "https://feed.url", "feed/1")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("a=feed%2F1") }
assertTrue {
contains(
"s=${
URLEncoder.encode(
"${GReaderDataSource.FEED_PREFIX}https://feed.url", "UTF-8"
)
}"
)
}
assertTrue { contains("ac=subscribe") }
}
}
@Test
fun deleteFeedTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.deleteFeed("token", "https://feed.url")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue {
contains(
"s=${
URLEncoder.encode(
"${GReaderDataSource.FEED_PREFIX}https://feed.url",
"UTF-8"
)
}"
)
}
assertTrue { contains("ac=unsubscribe") }
}
}
@Test
fun updateFeedTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue {
contains(
"s=${
URLEncoder.encode(
"${GReaderDataSource.FEED_PREFIX}https://feed.url",
"UTF-8"
)
}"
)
}
assertTrue { contains("t=title") }
assertTrue { contains("a=folderId") }
assertTrue { contains("ac=edit") }
}
}
@Test
fun createFolderTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.createFolder("token", "folder")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue {
contains(
"a=${
URLEncoder.encode(
"${GReaderDataSource.FOLDER_PREFIX}folder",
"UTF-8"
)
}"
)
}
}
}
@Test
fun updateFolderTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.updateFolder("token", "folderId", "folder")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=folderId") }
assertTrue {
contains(
"dest=${
URLEncoder.encode(
"${GReaderDataSource.FOLDER_PREFIX}folder",
"UTF-8"
)
}"
)
}
}
}
@Test
fun deleteFolderTest() = runTest {
mockServer.enqueueOK()
freshRSSDataSource.deleteFolder("token", "folderId")
val request = mockServer.takeRequest()
with(request.body.readUtf8()) {
assertTrue { contains("T=token") }
assertTrue { contains("s=folderId") }
}
}
@Test
fun initialSyncTest() = runTest {
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
return when {
contains("tag/list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/folders.json"))
}
contains("subscription/list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/feeds.json"))
}
// items
contains("contents/user/-/state/com.google/reading-list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items.json"))
}
// starred items
contains("contents/user/-/state/com.google/starred") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items.json"))
}
// unread ids & starred ids
contains("stream/items/ids") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items_starred_ids.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result =
freshRSSDataSource.synchronize(SyncType.INITIAL_SYNC, GReaderSyncData(), "writeToken")
with(result) {
assertEquals(1, folders.size)
assertEquals(1, feeds.size)
assertEquals(2, items.size)
assertEquals(2, starredItems.size)
assertEquals(5, unreadIds.size)
assertEquals(5, starredIds.size)
}
}
@Test
fun classicSync() = runTest {
var setItemState = 0
val ids = listOf("1", "2", "3", "4")
val lastModified = 10L
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
// printing request path before anything prevents a request being ignored and the test fail, I don't really know why
println("request: ${request.path}")
return when {
contains("0/edit-tag") -> {
setItemState++
MockResponse().setResponseCode(200)
}
contains("tag/list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/folders.json"))
}
contains("subscription/list") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/feeds.json"))
}
// items
contains("contents/user/-/state/com.google/reading-list") -> {
assertTrue { request.path!!.contains("ot=$lastModified") }
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items.json"))
}
// unread & read ids
contains("stream/items/ids") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/greader/adapters/items_starred_ids.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result = freshRSSDataSource.synchronize(
syncType = SyncType.CLASSIC_SYNC,
syncData = GReaderSyncData(
lastModified = 10L,
readIds = ids,
unreadIds = ids,
starredIds = ids,
unstarredIds = ids
),
writeToken = "writeToken"
)
with(result) {
assertEquals(4, setItemState)
assertEquals(1, folders.size)
assertEquals(1, feeds.size)
assertEquals(2, items.size)
assertEquals(5, unreadIds.size)
assertEquals(5, readIds.size)
assertEquals(5, starredIds.size)
}
}
}

View File

@ -1,389 +0,0 @@
package com.readrops.api.services.nextcloudnews
import com.readrops.api.TestUtils
import com.readrops.api.apiModule
import com.readrops.api.enqueueOK
import com.readrops.api.enqueueOKStream
import com.readrops.api.okResponseWithBody
import com.readrops.api.services.SyncType
import com.readrops.db.entities.account.Account
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import kotlinx.coroutines.test.runTest
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.test.get
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class NextcloudNewsDataSourceTest : KoinTest {
private lateinit var nextcloudNewsDataSource: NextcloudNewsDataSource
private val mockServer = MockWebServer()
private val moshi = Moshi.Builder()
.build()
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(apiModule, module {
single {
Retrofit.Builder()
.baseUrl("http://localhost:8080/")
.client(get())
.addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi"))))
.build()
.create(NextcloudNewsService::class.java)
}
})
}
@Before
fun before() {
mockServer.start(8080)
nextcloudNewsDataSource = NextcloudNewsDataSource(get())
}
@After
fun tearDown() {
mockServer.shutdown()
}
@Test
fun loginTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/user.xml")
val account = Account(login = "login", url = mockServer.url("").toString())
mockServer.enqueueOKStream(stream)
val displayName = nextcloudNewsDataSource.login(get(), account)
val request = mockServer.takeRequest()
assertTrue { displayName == "Shinokuni" }
assertTrue { request.headers.contains("OCS-APIRequest" to "true") }
assertTrue { request.path == "//ocs/v1.php/cloud/users/login" }
}
@Test
fun foldersTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json")
mockServer.enqueueOKStream(stream)
val folders = nextcloudNewsDataSource.getFolders()
assertTrue { folders.size == 1 }
}
@Test
fun feedsTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json")
mockServer.enqueueOKStream(stream)
val feeds = nextcloudNewsDataSource.getFeeds()
assertTrue { feeds.size == 3 }
}
@Test
fun itemsTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
mockServer.enqueueOKStream(stream)
val type = NextcloudNewsDataSource.ItemQueryType.ALL.value
val items = nextcloudNewsDataSource.getItems(
type = type,
read = false,
batchSize = 10
)
val request = mockServer.takeRequest()
assertTrue { items.size == 2 }
with(request.requestUrl!!) {
assertEquals("$type", queryParameter("type"))
assertEquals("false", queryParameter("getRead"))
assertEquals("10", queryParameter("batchSize"))
}
}
@Test
fun newItemsTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
mockServer.enqueueOKStream(stream)
val items =
nextcloudNewsDataSource.getNewItems(1512, NextcloudNewsDataSource.ItemQueryType.ALL)
val request = mockServer.takeRequest()
assertTrue { items.size == 2 }
with(request.requestUrl!!) {
assertEquals("1512", queryParameter("lastModified"))
assertEquals("3", queryParameter("type"))
}
}
@Test
fun createFeedTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json")
mockServer.enqueueOKStream(stream)
val feeds = nextcloudNewsDataSource.createFeed("https://news.ycombinator.com/rss", 100)
val request = mockServer.takeRequest()
assertTrue { feeds.isNotEmpty() }
with(request.requestUrl!!) {
assertEquals("https://news.ycombinator.com/rss", queryParameter("url"))
assertEquals("100", queryParameter("folderId"))
}
}
@Test
fun deleteFeedTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.deleteFeed(15)
val request = mockServer.takeRequest()
assertTrue { request.path!!.endsWith("/15") }
}
@Test
fun changeFeedFolderTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.changeFeedFolder(15, 18)
val request = mockServer.takeRequest()
val type = Types.newParameterizedType(
Map::class.java,
String::class.java,
Int::class.javaObjectType
)
val adapter = moshi.adapter<Map<String, Int>>(type)
val body = adapter.fromJson(request.body)!!
assertTrue { request.path!!.endsWith("/18/move") }
assertEquals(15, body["folderId"])
}
@Test
fun renameFeedTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.renameFeed("name", 15)
val request = mockServer.takeRequest()
val type =
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
val adapter = moshi.adapter<Map<String, String>>(type)
val body = adapter.fromJson(request.body)!!
assertTrue { request.path!!.endsWith("/15/rename") }
assertEquals("name", body["feedTitle"])
}
@Test
fun createFolderTest() = runTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json")
mockServer.enqueueOKStream(stream)
val folders = nextcloudNewsDataSource.createFolder("folder name")
val request = mockServer.takeRequest()
val type =
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
val adapter = moshi.adapter<Map<String, String>>(type)
val body = adapter.fromJson(request.body)!!
assertTrue { folders.size == 1 }
assertEquals("folder name", body["name"])
}
@Test
fun renameFolderTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.renameFolder("new name", 15)
val request = mockServer.takeRequest()
val type =
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
val adapter = moshi.adapter<Map<String, String>>(type)
val body = adapter.fromJson(request.body)!!
assertTrue { request.path!!.endsWith("/15") }
assertEquals("new name", body["name"])
}
@Test
fun deleteFolderTest() = runTest {
mockServer.enqueueOK()
nextcloudNewsDataSource.deleteFolder(15)
val request = mockServer.takeRequest()
assertEquals(request.method, "DELETE")
assertTrue { request.path!!.endsWith("/15") }
}
@Test
fun setItemsReadStateTest() = runTest {
mockServer.enqueueOK()
mockServer.enqueueOK()
val data = NextcloudNewsSyncData(
readIds = listOf(15, 16, 17),
unreadIds = listOf(18, 19, 20)
)
nextcloudNewsDataSource.setItemsReadState(data)
val unreadRequest = mockServer.takeRequest()
val readRequest = mockServer.takeRequest()
val type =
Types.newParameterizedType(
Map::class.java,
String::class.java,
Types.newParameterizedType(List::class.java, Int::class.javaObjectType)
)
val adapter = moshi.adapter<Map<String, List<Int>>>(type)
val unreadBody = adapter.fromJson(unreadRequest.body)!!
val readBody = adapter.fromJson(readRequest.body)!!
assertEquals(data.readIds, readBody["itemIds"])
assertEquals(data.unreadIds, unreadBody["itemIds"])
}
@Test
fun setItemsStarStateTest() = runTest {
mockServer.enqueueOK()
mockServer.enqueueOK()
val data = NextcloudNewsSyncData(
starredIds = listOf(15, 16, 17),
unstarredIds = listOf(18, 19, 20)
)
nextcloudNewsDataSource.setItemsStarState(data)
val starRequest = mockServer.takeRequest()
val unstarRequest = mockServer.takeRequest()
val type = Types.newParameterizedType(
Map::class.java,
String::class.java,
Types.newParameterizedType(List::class.java, Int::class.javaObjectType)
)
val adapter = moshi.adapter<Map<String, List<Int>>>(type)
val starBody = adapter.fromJson(starRequest.body)!!
val unstarBody = adapter.fromJson(unstarRequest.body)!!
assertEquals(data.starredIds, starBody["itemIds"])
assertEquals(data.unstarredIds, unstarBody["itemIds"])
}
@Test
fun initialSyncTest() = runTest {
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
return when {
this == "/folders" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json"))
}
this == "/feeds" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json"))
}
contains("/items") -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/items.json"))
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result =
nextcloudNewsDataSource.synchronize(SyncType.INITIAL_SYNC, NextcloudNewsSyncData())
with(result) {
assertEquals(1, folders.size)
assertEquals(3, feeds.size)
assertEquals(2, items.size)
assertEquals(2, starredItems.size)
}
}
@Test
fun classicSyncTest() = runTest {
var setItemState = 0
val lastModified = 10L
val ids = listOf(1, 2, 3, 4)
mockServer.dispatcher = object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
with(request.path!!) {
// important, otherwise test fails and I don't know why
println("request: ${request.path}")
return when {
this == "/folders" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json"))
}
this == "/feeds" -> {
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json"))
}
contains("/items/updated") -> {
assertEquals(
"$lastModified",
request.requestUrl!!.queryParameter("lastModified")
)
MockResponse.okResponseWithBody(TestUtils.loadResource("services/nextcloudnews/adapters/items.json"))
}
this.matches(Regex("/items/(read|unread|star|unstar)/multiple")) -> {
setItemState++
MockResponse().setResponseCode(200)
}
else -> MockResponse().setResponseCode(404)
}
}
}
}
val result = nextcloudNewsDataSource.synchronize(
SyncType.CLASSIC_SYNC,
NextcloudNewsSyncData(
lastModified = lastModified,
readIds = ids,
unreadIds = ids,
starredIds = ids,
unstarredIds = ids
)
)
with(result) {
assertEquals(4, setItemState)
assertEquals(1, folders.size)
assertEquals(3, feeds.size)
assertEquals(2, items.size)
}
}
}

View File

@ -9,10 +9,10 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class NextcloudNewsFeedsAdapterTest {
class NextNewsFeedsAdapterTest {
private val adapter = Moshi.Builder()
.add(NextcloudNewsFeedsAdapter())
.add(NextNewsFeedsAdapter())
.build()
.adapter<List<Feed>>(Types.newParameterizedType(List::class.java, Feed::class.java))

View File

@ -10,10 +10,10 @@ import okio.Buffer
import org.junit.Assert.assertThrows
import org.junit.Test
class NextcloudNewsFoldersAdapterTest {
class NextNewsFoldersAdapterTest {
private val adapter = Moshi.Builder()
.add(NextcloudNewsFoldersAdapter())
.add(NextNewsFoldersAdapter())
.build()
.adapter<List<Folder>>(Types.newParameterizedType(List::class.java, Folder::class.java))

View File

@ -2,17 +2,17 @@ package com.readrops.api.services.nextcloudnews.adapters
import com.readrops.api.TestUtils
import com.readrops.db.entities.Item
import com.readrops.db.util.DateUtils
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import junit.framework.TestCase.assertEquals
import okio.Buffer
import org.joda.time.LocalDateTime
import org.junit.Test
class NextcloudNewsItemsAdapterTest {
class NextNewsItemsAdapterTest {
private val adapter = Moshi.Builder()
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextcloudNewsItemsAdapter())
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter())
.build()
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
@ -21,12 +21,11 @@ class NextcloudNewsItemsAdapterTest {
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
val items = adapter.fromJson(Buffer().readFrom(stream))!!
val item = items.first()
assertEquals(2, items.size)
val item = items[0]
with(item) {
assertEquals(remoteId, "3443")
assertEquals(guid, "3059047a572cd9cd5d0bf645faffd077")
assertEquals(link, "http://grulja.wordpress.com/2013/04/29/plasma-nm-after-the-solid-sprint/")
assertEquals(title, "Plasma-nm after the solid sprint")
assertEquals(author, "Jan Grulich (grulja)")
@ -34,12 +33,12 @@ class NextcloudNewsItemsAdapterTest {
assertEquals(feedRemoteId, "67")
assertEquals(isRead, false)
assertEquals(isStarred, false)
assertEquals(pubDate, DateUtils.fromEpochSeconds(1367270544))
assertEquals(imageLink, "https://test.org/image.jpg")
assertEquals(pubDate, LocalDateTime(1367270544000))
assertEquals(imageLink, null)
}
with(items[1]) {
assertEquals(imageLink, null)
assertEquals(imageLink, "https://test.org/image.jpg")
}
}

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