mirror of https://github.com/readrops/Readrops.git
Merge remote-tracking branch 'origin/develop' into feature/fever_api
This commit is contained in:
commit
04820cd700
|
@ -11,21 +11,27 @@ on:
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|
||||||
runs-on: macos-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- name: set up JDK 1.8
|
- name: set up JDK 1.17
|
||||||
uses: actions/setup-java@v1
|
uses: actions/setup-java@v3
|
||||||
with:
|
with:
|
||||||
java-version: 1.8
|
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
|
||||||
- name: Android Emulator Runner
|
- name: Android Emulator Runner
|
||||||
uses: ReactiveCircus/android-emulator-runner@v2.20.0
|
uses: ReactiveCircus/android-emulator-runner@v2.30.1
|
||||||
with:
|
with:
|
||||||
api-level: 29
|
api-level: 29
|
||||||
script: ./gradlew clean build connectedCheck jacocoFullReport
|
script: ./gradlew clean build connectedCheck
|
||||||
- uses: codecov/codecov-action@v2.1.0
|
- uses: codecov/codecov-action@v2.1.0
|
||||||
with:
|
with:
|
||||||
files: ./build/reports/jacoco/jacocoFullReport.xml
|
files: ./build/reports/jacoco/jacocoFullReport.xml
|
||||||
fail_ci_if_error: true
|
fail_ci_if_error: false
|
||||||
verbose: true
|
verbose: true
|
|
@ -135,3 +135,5 @@ Gemfile
|
||||||
mapping/
|
mapping/
|
||||||
|
|
||||||
**/*.exec
|
**/*.exec
|
||||||
|
|
||||||
|
.kotlin/
|
||||||
|
|
|
@ -1,83 +0,0 @@
|
||||||
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"
|
|
||||||
}
|
|
||||||
|
|
||||||
lintOptions {
|
|
||||||
abortOnError false
|
|
||||||
}
|
|
||||||
|
|
||||||
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_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = '1.8'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
|
|
||||||
implementation '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'
|
|
||||||
|
|
||||||
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2"
|
|
||||||
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1"
|
|
||||||
api "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.5.1"
|
|
||||||
}
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.library")
|
||||||
|
kotlin("android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.readrops.api"
|
||||||
|
|
||||||
|
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)
|
||||||
|
// I don't know why but those dependencies are unreachable when accessed directly from version catalog
|
||||||
|
testImplementation("io.insert-koin:koin-test:${libs.versions.koin.bom.get()}")
|
||||||
|
testImplementation("io.insert-koin:koin-test-junit4:${libs.versions.koin.bom.get()}")
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="com.readrops.api">
|
|
||||||
|
|
||||||
<!-- for tests only -->
|
<!-- for tests only -->
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="com.readrops.api">
|
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
package com.readrops.api
|
package com.readrops.api
|
||||||
|
|
||||||
import com.chimerapps.niddler.interceptor.okhttp.NiddlerOkHttpInterceptor
|
|
||||||
import com.readrops.api.localfeed.LocalRSSDataSource
|
import com.readrops.api.localfeed.LocalRSSDataSource
|
||||||
import com.readrops.api.services.Credentials
|
import com.readrops.api.services.Credentials
|
||||||
import com.readrops.api.services.fever.FeverDataSource
|
import com.readrops.api.services.fever.FeverDataSource
|
||||||
|
@ -8,13 +7,18 @@ import com.readrops.api.services.fever.FeverService
|
||||||
import com.readrops.api.services.fever.adapters.*
|
import com.readrops.api.services.fever.adapters.*
|
||||||
import com.readrops.api.services.freshrss.FreshRSSDataSource
|
import com.readrops.api.services.freshrss.FreshRSSDataSource
|
||||||
import com.readrops.api.services.freshrss.FreshRSSService
|
import com.readrops.api.services.freshrss.FreshRSSService
|
||||||
import com.readrops.api.services.freshrss.adapters.*
|
import com.readrops.api.services.freshrss.adapters.FreshRSSFeedsAdapter
|
||||||
import com.readrops.api.services.nextcloudnews.NextNewsDataSource
|
import com.readrops.api.services.freshrss.adapters.FreshRSSFoldersAdapter
|
||||||
import com.readrops.api.services.nextcloudnews.NextNewsService
|
import com.readrops.api.services.freshrss.adapters.FreshRSSItemsAdapter
|
||||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFeedsAdapter
|
import com.readrops.api.services.freshrss.adapters.FreshRSSItemsIdsAdapter
|
||||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFoldersAdapter
|
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfoAdapter
|
||||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsItemsAdapter
|
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.utils.AuthInterceptor
|
import com.readrops.api.utils.AuthInterceptor
|
||||||
|
import com.readrops.api.utils.ErrorInterceptor
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
|
@ -22,7 +26,6 @@ import okhttp3.OkHttpClient
|
||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
|
|
||||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
@ -30,15 +33,18 @@ val apiModule = module {
|
||||||
|
|
||||||
single {
|
single {
|
||||||
OkHttpClient.Builder()
|
OkHttpClient.Builder()
|
||||||
.callTimeout(1, TimeUnit.MINUTES)
|
.callTimeout(1, TimeUnit.MINUTES)
|
||||||
.readTimeout(1, TimeUnit.HOURS)
|
.readTimeout(1, TimeUnit.MINUTES)
|
||||||
.addInterceptor(get<AuthInterceptor>())
|
.addInterceptor(get<AuthInterceptor>())
|
||||||
.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
|
.addInterceptor(get<ErrorInterceptor>())
|
||||||
.build()
|
//.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
single { AuthInterceptor() }
|
single { AuthInterceptor() }
|
||||||
|
|
||||||
|
single { ErrorInterceptor() }
|
||||||
|
|
||||||
single { LocalRSSDataSource(get()) }
|
single { LocalRSSDataSource(get()) }
|
||||||
|
|
||||||
//region freshrss
|
//region freshrss
|
||||||
|
@ -47,12 +53,11 @@ val apiModule = module {
|
||||||
|
|
||||||
factory { (credentials: Credentials) ->
|
factory { (credentials: Credentials) ->
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(credentials.url)
|
.baseUrl(credentials.url)
|
||||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
.client(get())
|
||||||
.client(get())
|
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
|
||||||
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
|
.build()
|
||||||
.build()
|
.create(FreshRSSService::class.java)
|
||||||
.create(FreshRSSService::class.java)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
single(named("freshrssMoshi")) {
|
single(named("freshrssMoshi")) {
|
||||||
|
@ -69,23 +74,22 @@ val apiModule = module {
|
||||||
|
|
||||||
//region nextcloud news
|
//region nextcloud news
|
||||||
|
|
||||||
factory { params -> NextNewsDataSource(get(parameters = { params })) }
|
factory { params -> NextcloudNewsDataSource(get(parameters = { params })) }
|
||||||
|
|
||||||
factory { (credentials: Credentials) ->
|
factory { (credentials: Credentials) ->
|
||||||
Retrofit.Builder()
|
Retrofit.Builder()
|
||||||
.baseUrl(credentials.url)
|
.baseUrl(credentials.url)
|
||||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
|
||||||
.client(get())
|
.client(get())
|
||||||
.addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi"))))
|
.addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi"))))
|
||||||
.build()
|
.build()
|
||||||
.create(NextNewsService::class.java)
|
.create(NextcloudNewsService::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
single(named("nextcloudNewsMoshi")) {
|
single(named("nextcloudNewsMoshi")) {
|
||||||
Moshi.Builder()
|
Moshi.Builder()
|
||||||
.add(NextNewsFeedsAdapter())
|
.add(NextcloudNewsFeedsAdapter())
|
||||||
.add(NextNewsFoldersAdapter())
|
.add(NextcloudNewsFoldersAdapter())
|
||||||
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter())
|
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextcloudNewsItemsAdapter())
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
package com.readrops.api.localfeed
|
package com.readrops.api.localfeed
|
||||||
|
|
||||||
import android.accounts.NetworkErrorException
|
|
||||||
import androidx.annotation.WorkerThread
|
import androidx.annotation.WorkerThread
|
||||||
import com.gitlab.mvysny.konsumexml.Konsumer
|
import com.gitlab.mvysny.konsumexml.Konsumer
|
||||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||||
import com.readrops.api.localfeed.json.JSONFeedAdapter
|
import com.readrops.api.localfeed.json.JSONFeedAdapter
|
||||||
import com.readrops.api.utils.ApiUtils
|
import com.readrops.api.utils.ApiUtils
|
||||||
import com.readrops.api.utils.AuthInterceptor
|
import com.readrops.api.utils.AuthInterceptor
|
||||||
|
import com.readrops.api.utils.exceptions.HttpException
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.api.utils.exceptions.UnknownFormatException
|
import com.readrops.api.utils.exceptions.UnknownFormatException
|
||||||
import com.readrops.db.entities.Feed
|
import com.readrops.db.entities.Feed
|
||||||
|
@ -21,7 +21,6 @@ import okio.Buffer
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.get
|
import org.koin.core.component.get
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.lang.Exception
|
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
|
|
||||||
class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
||||||
|
@ -32,7 +31,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
||||||
* @param headers request headers
|
* @param headers request headers
|
||||||
* @return a Feed object with its items
|
* @return a Feed object with its items
|
||||||
*/
|
*/
|
||||||
@Throws(ParseException::class, UnknownFormatException::class, NetworkErrorException::class, IOException::class)
|
@Throws(ParseException::class, UnknownFormatException::class, HttpException::class, IOException::class)
|
||||||
@WorkerThread
|
@WorkerThread
|
||||||
fun queryRSSResource(url: String, headers: Headers?): Pair<Feed, List<Item>>? {
|
fun queryRSSResource(url: String, headers: Headers?): Pair<Feed, List<Item>>? {
|
||||||
get<AuthInterceptor>().credentials = null
|
get<AuthInterceptor>().credentials = null
|
||||||
|
@ -46,7 +45,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
||||||
pair
|
pair
|
||||||
}
|
}
|
||||||
response.code == HttpURLConnection.HTTP_NOT_MODIFIED -> null
|
response.code == HttpURLConnection.HTTP_NOT_MODIFIED -> null
|
||||||
else -> throw NetworkErrorException("$url returned ${response.code} code : ${response.message}")
|
else -> throw HttpException(response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -74,7 +73,8 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
||||||
val rootKonsumer = nextElement(LocalRSSHelper.RSS_ROOT_NAMES)
|
val rootKonsumer = nextElement(LocalRSSHelper.RSS_ROOT_NAMES)
|
||||||
rootKonsumer?.let { type = LocalRSSHelper.guessRSSType(rootKonsumer) }
|
rootKonsumer?.let { type = LocalRSSHelper.guessRSSType(rootKonsumer) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
throw UnknownFormatException(e.message)
|
close()
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,6 +29,7 @@ class ATOMFeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
|
||||||
"link" -> parseLink(this@allChildrenAutoIgnore, feed)
|
"link" -> parseLink(this@allChildrenAutoIgnore, feed)
|
||||||
"subtitle" -> description = nullableText()
|
"subtitle" -> description = nullableText()
|
||||||
"entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
|
"entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
|
||||||
|
else -> skipContents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,13 +4,13 @@ import com.gitlab.mvysny.konsumexml.Konsumer
|
||||||
import com.gitlab.mvysny.konsumexml.Names
|
import com.gitlab.mvysny.konsumexml.Names
|
||||||
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
|
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
|
||||||
import com.readrops.api.localfeed.XmlAdapter
|
import com.readrops.api.localfeed.XmlAdapter
|
||||||
import com.readrops.api.utils.*
|
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.api.utils.extensions.nonNullText
|
import com.readrops.api.utils.extensions.nonNullText
|
||||||
import com.readrops.api.utils.extensions.nullableText
|
import com.readrops.api.utils.extensions.nullableText
|
||||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
import org.joda.time.LocalDateTime
|
import com.readrops.db.util.DateUtils
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class ATOMItemAdapter : XmlAdapter<Item> {
|
class ATOMItemAdapter : XmlAdapter<Item> {
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ class ATOMItemAdapter : XmlAdapter<Item> {
|
||||||
konsumer.allChildrenAutoIgnore(names) {
|
konsumer.allChildrenAutoIgnore(names) {
|
||||||
when (tagName) {
|
when (tagName) {
|
||||||
"title" -> title = nonNullText()
|
"title" -> title = nonNullText()
|
||||||
"id" -> guid = nullableText()
|
"id" -> remoteId = nullableText()
|
||||||
"updated" -> pubDate = DateUtils.parse(nullableText())
|
"updated" -> pubDate = DateUtils.parse(nullableText())
|
||||||
"link" -> parseLink(this, this@apply)
|
"link" -> parseLink(this, this@apply)
|
||||||
"author" -> allChildrenAutoIgnore("name") { author = nullableText() }
|
"author" -> allChildrenAutoIgnore("name") { author = nullableText() }
|
||||||
|
@ -35,7 +35,7 @@ class ATOMItemAdapter : XmlAdapter<Item> {
|
||||||
|
|
||||||
validateItem(item)
|
validateItem(item)
|
||||||
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
|
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
|
||||||
if (item.guid == null) item.guid = item.link
|
if (item.remoteId == null) item.remoteId = item.link
|
||||||
|
|
||||||
item
|
item
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
package com.readrops.api.localfeed.json
|
package com.readrops.api.localfeed.json
|
||||||
|
|
||||||
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
|
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.exceptions.ParseException
|
||||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||||
import com.readrops.api.utils.extensions.nextNullableString
|
import com.readrops.api.utils.extensions.nextNullableString
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
|
import com.readrops.db.util.DateUtils
|
||||||
import com.squareup.moshi.JsonAdapter
|
import com.squareup.moshi.JsonAdapter
|
||||||
import com.squareup.moshi.JsonReader
|
import com.squareup.moshi.JsonReader
|
||||||
import com.squareup.moshi.JsonWriter
|
import com.squareup.moshi.JsonWriter
|
||||||
import org.joda.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class JSONItemsAdapter : JsonAdapter<List<Item>>() {
|
class JSONItemsAdapter : JsonAdapter<List<Item>>() {
|
||||||
|
|
||||||
|
@ -33,7 +33,7 @@ class JSONItemsAdapter : JsonAdapter<List<Item>>() {
|
||||||
while (hasNext()) {
|
while (hasNext()) {
|
||||||
with(item) {
|
with(item) {
|
||||||
when (selectName(names)) {
|
when (selectName(names)) {
|
||||||
0 -> guid = nextNonEmptyString()
|
0 -> remoteId = nextNonEmptyString()
|
||||||
1 -> link = nextNonEmptyString()
|
1 -> link = nextNonEmptyString()
|
||||||
2 -> title = nextNonEmptyString()
|
2 -> title = nextNonEmptyString()
|
||||||
3 -> contentHtml = nextNullableString()
|
3 -> contentHtml = nextNullableString()
|
||||||
|
|
|
@ -26,6 +26,7 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
|
||||||
when (tagName) {
|
when (tagName) {
|
||||||
"channel" -> parseChannel(this, feed)
|
"channel" -> parseChannel(this, feed)
|
||||||
"item" -> items += itemAdapter.fromXml(this)
|
"item" -> items += itemAdapter.fromXml(this)
|
||||||
|
else -> skipContents()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,13 +5,13 @@ import com.gitlab.mvysny.konsumexml.Names
|
||||||
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
|
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
|
||||||
import com.readrops.api.localfeed.XmlAdapter
|
import com.readrops.api.localfeed.XmlAdapter
|
||||||
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
|
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.exceptions.ParseException
|
||||||
import com.readrops.api.utils.extensions.nonNullText
|
import com.readrops.api.utils.extensions.nonNullText
|
||||||
import com.readrops.api.utils.extensions.nullableText
|
import com.readrops.api.utils.extensions.nullableText
|
||||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
import org.joda.time.LocalDateTime
|
import com.readrops.db.util.DateUtils
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class RSS1ItemAdapter : XmlAdapter<Item> {
|
class RSS1ItemAdapter : XmlAdapter<Item> {
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ class RSS1ItemAdapter : XmlAdapter<Item> {
|
||||||
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
|
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
|
||||||
if (item.link == null) item.link = about
|
if (item.link == null) item.link = about
|
||||||
?: throw ParseException("RSS1 link or about element is required")
|
?: throw ParseException("RSS1 link or about element is required")
|
||||||
item.guid = item.link
|
item.remoteId = item.link
|
||||||
|
|
||||||
if (authors.filterNotNull().isNotEmpty()) item.author = authors.filterNotNull()
|
if (authors.filterNotNull().isNotEmpty()) item.author = authors.filterNotNull()
|
||||||
.joinToString(limit = AUTHORS_MAX)
|
.joinToString(limit = AUTHORS_MAX)
|
||||||
|
|
|
@ -1,15 +1,19 @@
|
||||||
package com.readrops.api.localfeed.rss2
|
package com.readrops.api.localfeed.rss2
|
||||||
|
|
||||||
import com.gitlab.mvysny.konsumexml.*
|
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.XmlAdapter
|
import com.readrops.api.localfeed.XmlAdapter
|
||||||
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
|
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
|
||||||
import com.readrops.api.utils.*
|
import com.readrops.api.utils.ApiUtils
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.api.utils.extensions.nonNullText
|
import com.readrops.api.utils.extensions.nonNullText
|
||||||
import com.readrops.api.utils.extensions.nullableText
|
import com.readrops.api.utils.extensions.nullableText
|
||||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
import org.joda.time.LocalDateTime
|
import com.readrops.db.util.DateUtils
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
class RSS2ItemAdapter : XmlAdapter<Item> {
|
class RSS2ItemAdapter : XmlAdapter<Item> {
|
||||||
|
|
||||||
|
@ -29,7 +33,7 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
|
||||||
"dc:creator" -> creators += nullableText()
|
"dc:creator" -> creators += nullableText()
|
||||||
"pubDate" -> pubDate = DateUtils.parse(nullableText())
|
"pubDate" -> pubDate = DateUtils.parse(nullableText())
|
||||||
"dc:date" -> pubDate = DateUtils.parse(nullableText())
|
"dc:date" -> pubDate = DateUtils.parse(nullableText())
|
||||||
"guid" -> guid = nullableText()
|
"guid" -> remoteId = nullableText()
|
||||||
"description" -> description = nullableTextRecursively()
|
"description" -> description = nullableTextRecursively()
|
||||||
"content:encoded" -> content = nullableTextRecursively()
|
"content:encoded" -> content = nullableTextRecursively()
|
||||||
"enclosure" -> parseEnclosure(this, item = this@apply)
|
"enclosure" -> parseEnclosure(this, item = this@apply)
|
||||||
|
@ -81,7 +85,7 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
|
||||||
validateItem(this)
|
validateItem(this)
|
||||||
|
|
||||||
if (pubDate == null) pubDate = LocalDateTime.now()
|
if (pubDate == null) pubDate = LocalDateTime.now()
|
||||||
if (guid == null) guid = link
|
if (remoteId == null) remoteId = link
|
||||||
if (author == null && creators.filterNotNull().isNotEmpty())
|
if (author == null && creators.filterNotNull().isNotEmpty())
|
||||||
author = creators.filterNotNull().joinToString(limit = AUTHORS_MAX)
|
author = creators.filterNotNull().joinToString(limit = AUTHORS_MAX)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,59 +1,45 @@
|
||||||
package com.readrops.api.opml
|
package com.readrops.api.opml
|
||||||
|
|
||||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||||
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.db.entities.Feed
|
import com.readrops.db.entities.Feed
|
||||||
import com.readrops.db.entities.Folder
|
import com.readrops.db.entities.Folder
|
||||||
import io.reactivex.Completable
|
|
||||||
import io.reactivex.Single
|
|
||||||
import org.redundent.kotlin.xml.xml
|
import org.redundent.kotlin.xml.xml
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
import java.io.OutputStream
|
import java.io.OutputStream
|
||||||
|
|
||||||
object OPMLParser {
|
object OPMLParser {
|
||||||
|
|
||||||
@JvmStatic
|
suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> {
|
||||||
fun read(stream: InputStream): Single<Map<Folder?, List<Feed>>> {
|
try {
|
||||||
return Single.create { emitter ->
|
val adapter = OPMLAdapter()
|
||||||
try {
|
val opml = adapter.fromXml(stream.konsumeXml())
|
||||||
val adapter = OPMLAdapter()
|
|
||||||
val opml = adapter.fromXml(stream.konsumeXml())
|
|
||||||
|
|
||||||
emitter.onSuccess(opml)
|
stream.close()
|
||||||
} catch (e: Exception) {
|
return opml
|
||||||
emitter.onError(e)
|
} catch (e: Exception) {
|
||||||
}
|
throw ParseException(e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@JvmStatic
|
suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) {
|
||||||
fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream): Completable {
|
val opml = xml("opml") {
|
||||||
return Completable.create { emitter ->
|
attribute("version", "2.0")
|
||||||
val opml = xml("opml") {
|
|
||||||
attribute("version", "2.0")
|
|
||||||
|
|
||||||
"head" {
|
"head" {
|
||||||
-"Subscriptions"
|
-"Subscriptions"
|
||||||
}
|
}
|
||||||
|
|
||||||
"body" {
|
"body" {
|
||||||
for (folderAndFeeds in foldersAndFeeds) {
|
for (folderAndFeeds in foldersAndFeeds) {
|
||||||
if (folderAndFeeds.key != null) { // feeds with folder
|
if (folderAndFeeds.key != null) { // feeds with folder
|
||||||
"outline" {
|
"outline" {
|
||||||
folderAndFeeds.key?.name?.let {
|
folderAndFeeds.key?.name?.let {
|
||||||
attribute("title", it)
|
attribute("title", it)
|
||||||
attribute("text", it)
|
attribute("text", it)
|
||||||
}
|
|
||||||
|
|
||||||
for (feed in folderAndFeeds.value) {
|
|
||||||
"outline" {
|
|
||||||
feed.name?.let { attribute("title", it) }
|
|
||||||
attribute("xmlUrl", feed.url!!)
|
|
||||||
feed.siteUrl?.let { attribute("htmlUrl", it) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
for (feed in folderAndFeeds.value) { // feeds without folder
|
for (feed in folderAndFeeds.value) {
|
||||||
"outline" {
|
"outline" {
|
||||||
feed.name?.let { attribute("title", it) }
|
feed.name?.let { attribute("title", it) }
|
||||||
attribute("xmlUrl", feed.url!!)
|
attribute("xmlUrl", feed.url!!)
|
||||||
|
@ -61,14 +47,21 @@ object OPMLParser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
for (feed in folderAndFeeds.value) { // feeds without folder
|
||||||
|
"outline" {
|
||||||
|
feed.name?.let { attribute("title", it) }
|
||||||
|
attribute("xmlUrl", feed.url!!)
|
||||||
|
feed.siteUrl?.let { attribute("htmlUrl", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
outputStream.write(opml.toString().toByteArray())
|
|
||||||
outputStream.flush()
|
|
||||||
|
|
||||||
emitter.onComplete()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
outputStream.write(opml.toString().toByteArray())
|
||||||
|
outputStream.flush()
|
||||||
|
outputStream.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -3,8 +3,8 @@ package com.readrops.api.services
|
||||||
import com.readrops.api.services.fever.FeverCredentials
|
import com.readrops.api.services.fever.FeverCredentials
|
||||||
import com.readrops.api.services.freshrss.FreshRSSCredentials
|
import com.readrops.api.services.freshrss.FreshRSSCredentials
|
||||||
import com.readrops.api.services.freshrss.FreshRSSService
|
import com.readrops.api.services.freshrss.FreshRSSService
|
||||||
import com.readrops.api.services.nextcloudnews.NextNewsCredentials
|
import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials
|
||||||
import com.readrops.api.services.nextcloudnews.NextNewsService
|
import com.readrops.api.services.nextcloudnews.NextcloudNewsService
|
||||||
import com.readrops.db.entities.account.Account
|
import com.readrops.db.entities.account.Account
|
||||||
import com.readrops.db.entities.account.AccountType
|
import com.readrops.db.entities.account.AccountType
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ abstract class Credentials(val authorization: String?, val url: String) {
|
||||||
val endPoint = getEndPoint(account.accountType!!)
|
val endPoint = getEndPoint(account.accountType!!)
|
||||||
|
|
||||||
return when (account.accountType) {
|
return when (account.accountType) {
|
||||||
AccountType.NEXTCLOUD_NEWS -> NextNewsCredentials(account.login, account.password, account.url + endPoint)
|
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsCredentials(account.login, account.password, account.url + endPoint)
|
||||||
AccountType.FRESHRSS -> FreshRSSCredentials(account.token, account.url + endPoint)
|
AccountType.FRESHRSS -> FreshRSSCredentials(account.token, account.url + endPoint)
|
||||||
AccountType.FEVER -> FeverCredentials(account.login, account.password, account.url + endPoint)
|
AccountType.FEVER -> FeverCredentials(account.login, account.password, account.url + endPoint)
|
||||||
else -> throw IllegalArgumentException("Unknown account type")
|
else -> throw IllegalArgumentException("Unknown account type")
|
||||||
|
@ -26,7 +26,7 @@ abstract class Credentials(val authorization: String?, val url: String) {
|
||||||
private fun getEndPoint(accountType: AccountType): String {
|
private fun getEndPoint(accountType: AccountType): String {
|
||||||
return when (accountType) {
|
return when (accountType) {
|
||||||
AccountType.FRESHRSS -> FreshRSSService.END_POINT
|
AccountType.FRESHRSS -> FreshRSSService.END_POINT
|
||||||
AccountType.NEXTCLOUD_NEWS -> NextNewsService.END_POINT
|
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsService.END_POINT
|
||||||
AccountType.FEVER -> ""
|
AccountType.FEVER -> ""
|
||||||
else -> throw IllegalArgumentException("Unknown account type")
|
else -> throw IllegalArgumentException("Unknown account type")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
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(),
|
||||||
|
)
|
|
@ -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
|
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
|
@ -7,10 +7,10 @@ import com.readrops.api.utils.extensions.nextNullableString
|
||||||
import com.readrops.api.utils.extensions.skipField
|
import com.readrops.api.utils.extensions.skipField
|
||||||
import com.readrops.api.utils.extensions.toBoolean
|
import com.readrops.api.utils.extensions.toBoolean
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
|
import com.readrops.db.util.DateUtils
|
||||||
import com.squareup.moshi.FromJson
|
import com.squareup.moshi.FromJson
|
||||||
import com.squareup.moshi.JsonReader
|
import com.squareup.moshi.JsonReader
|
||||||
import com.squareup.moshi.ToJson
|
import com.squareup.moshi.ToJson
|
||||||
import org.joda.time.LocalDateTime
|
|
||||||
|
|
||||||
class FeverItemsAdapter {
|
class FeverItemsAdapter {
|
||||||
|
|
||||||
|
@ -47,7 +47,7 @@ class FeverItemsAdapter {
|
||||||
5 -> link = nextNullableString()
|
5 -> link = nextNullableString()
|
||||||
6 -> isRead = nextInt().toBoolean()
|
6 -> isRead = nextInt().toBoolean()
|
||||||
7 -> isStarred = nextInt().toBoolean()
|
7 -> isStarred = nextInt().toBoolean()
|
||||||
8 -> pubDate = LocalDateTime(nextLong() * 1000L)
|
8 -> pubDate = DateUtils.fromEpochSeconds(nextLong())
|
||||||
else -> skipValue()
|
else -> skipValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,316 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,164 @@
|
||||||
|
package com.readrops.api.services.freshrss
|
||||||
|
|
||||||
|
import com.readrops.api.services.DataSourceResult
|
||||||
|
import com.readrops.api.services.SyncType
|
||||||
|
import com.readrops.api.services.freshrss.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 FreshRSSDataSource(private val service: FreshRSSService) {
|
||||||
|
|
||||||
|
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: FreshRSSSyncData,
|
||||||
|
writeToken: String
|
||||||
|
): DataSourceResult = with(CoroutineScope(Dispatchers.IO)) {
|
||||||
|
return if (syncType == SyncType.INITIAL_SYNC) {
|
||||||
|
DataSourceResult().apply {
|
||||||
|
listOf(
|
||||||
|
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) }
|
||||||
|
).awaitAll()
|
||||||
|
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DataSourceResult().apply {
|
||||||
|
listOf(
|
||||||
|
async { setItemsReadState(syncData, writeToken) },
|
||||||
|
async { setItemsStarState(syncData, writeToken) },
|
||||||
|
).awaitAll()
|
||||||
|
|
||||||
|
listOf(
|
||||||
|
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) }
|
||||||
|
).awaitAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun setItemsReadState(syncData: FreshRSSSyncData, 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: FreshRSSSyncData, 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/"
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,65 +4,68 @@ import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
|
||||||
import com.readrops.db.entities.Feed
|
import com.readrops.db.entities.Feed
|
||||||
import com.readrops.db.entities.Folder
|
import com.readrops.db.entities.Folder
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
import io.reactivex.Completable
|
|
||||||
import io.reactivex.Single
|
|
||||||
import okhttp3.RequestBody
|
import okhttp3.RequestBody
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
import retrofit2.http.*
|
import retrofit2.http.Body
|
||||||
|
import retrofit2.http.Field
|
||||||
|
import retrofit2.http.FormUrlEncoded
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
interface FreshRSSService {
|
interface FreshRSSService {
|
||||||
|
|
||||||
@POST("accounts/ClientLogin")
|
@POST("accounts/ClientLogin")
|
||||||
fun login(@Body body: RequestBody?): Single<ResponseBody?>?
|
suspend fun login(@Body body: RequestBody?): ResponseBody
|
||||||
|
|
||||||
@get:GET("reader/api/0/token")
|
@GET("reader/api/0/token")
|
||||||
val writeToken: Single<ResponseBody>
|
suspend fun getWriteToken(): ResponseBody
|
||||||
|
|
||||||
@get:GET("reader/api/0/user-info")
|
@GET("reader/api/0/user-info")
|
||||||
val userInfo: Single<FreshRSSUserInfo>
|
suspend fun userInfo(): FreshRSSUserInfo
|
||||||
|
|
||||||
@get:GET("reader/api/0/subscription/list?output=json")
|
@GET("reader/api/0/subscription/list?output=json")
|
||||||
val feeds: Single<List<Feed>>
|
suspend fun getFeeds(): List<Feed>
|
||||||
|
|
||||||
@get:GET("reader/api/0/tag/list?output=json")
|
@GET("reader/api/0/tag/list?output=json")
|
||||||
val folders: Single<List<Folder>>
|
suspend fun getFolders(): List<Folder>
|
||||||
|
|
||||||
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list")
|
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list")
|
||||||
fun getItems(@Query("xt") excludeTarget: List<String>?, @Query("n") max: Int,
|
suspend fun getItems(@Query("xt") excludeTarget: List<String>?, @Query("n") max: Int,
|
||||||
@Query("ot") lastModified: Long?): Single<List<Item>>
|
@Query("ot") lastModified: Long?): List<Item>
|
||||||
|
|
||||||
@GET("reader/api/0/stream/contents/user/-/state/com.google/starred")
|
@GET("reader/api/0/stream/contents/user/-/state/com.google/starred")
|
||||||
fun getStarredItems(@Query("n") max: Int): Single<List<Item>>
|
suspend fun getStarredItems(@Query("n") max: Int): List<Item>
|
||||||
|
|
||||||
@GET("reader/api/0/stream/items/ids")
|
@GET("reader/api/0/stream/items/ids")
|
||||||
fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?,
|
suspend fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?,
|
||||||
@Query("n") max: Int): Single<List<String>>
|
@Query("n") max: Int): List<String>
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@POST("reader/api/0/edit-tag")
|
@POST("reader/api/0/edit-tag")
|
||||||
fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?,
|
suspend fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?,
|
||||||
@Field("r") removeAction: String?, @Field("i") itemIds: List<String>): Completable
|
@Field("r") removeAction: String?, @Field("i") itemIds: List<String>)
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@POST("reader/api/0/subscription/edit")
|
@POST("reader/api/0/subscription/edit")
|
||||||
fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String): Completable
|
suspend fun createOrDeleteFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("ac") action: String)
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@POST("reader/api/0/subscription/edit")
|
@POST("reader/api/0/subscription/edit")
|
||||||
fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String,
|
suspend fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String,
|
||||||
@Field("a") folderId: String, @Field("ac") action: String): Completable
|
@Field("a") folderId: String, @Field("ac") action: String)
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@POST("reader/api/0/edit-tag")
|
@POST("reader/api/0/edit-tag")
|
||||||
fun createFolder(@Field("T") token: String, @Field("a") tagName: String): Completable
|
suspend fun createFolder(@Field("T") token: String, @Field("a") tagName: String)
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@POST("reader/api/0/rename-tag")
|
@POST("reader/api/0/rename-tag")
|
||||||
fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String): Completable
|
suspend fun updateFolder(@Field("T") token: String, @Field("s") folderId: String, @Field("dest") newFolderId: String)
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@POST("reader/api/0/disable-tag")
|
@POST("reader/api/0/disable-tag")
|
||||||
fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String): Completable
|
suspend fun deleteFolder(@Field("T") token: String, @Field("s") folderId: String)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val END_POINT = "/api/greader.php/"
|
const val END_POINT = "/api/greader.php/"
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
package com.readrops.api.services.freshrss
|
package com.readrops.api.services.freshrss
|
||||||
|
|
||||||
data class FreshRSSSyncData(
|
data class FreshRSSSyncData(
|
||||||
var lastModified: Long = 0,
|
var lastModified: Long = 0,
|
||||||
var readItemsIds: List<String> = listOf(),
|
var readIds: List<String> = listOf(),
|
||||||
var unreadItemsIds: List<String> = listOf(),
|
var unreadIds: List<String> = listOf(),
|
||||||
var starredItemsIds: List<String> = listOf(),
|
var starredIds: List<String> = listOf(),
|
||||||
var unstarredItemsIds: List<String> = listOf(),
|
var unstarredIds: List<String> = listOf(),
|
||||||
)
|
)
|
|
@ -1,17 +1,15 @@
|
||||||
package com.readrops.api.services.freshrss.adapters
|
package com.readrops.api.services.freshrss.adapters
|
||||||
|
|
||||||
import android.util.TimingLogger
|
import com.readrops.api.services.freshrss.FreshRSSDataSource.Companion.GOOGLE_READ
|
||||||
import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_READ
|
import com.readrops.api.services.freshrss.FreshRSSDataSource.Companion.GOOGLE_STARRED
|
||||||
import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_STARRED
|
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||||
import com.readrops.api.utils.extensions.nextNullableString
|
import com.readrops.api.utils.extensions.nextNullableString
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
|
import com.readrops.db.util.DateUtils
|
||||||
import com.squareup.moshi.JsonAdapter
|
import com.squareup.moshi.JsonAdapter
|
||||||
import com.squareup.moshi.JsonReader
|
import com.squareup.moshi.JsonReader
|
||||||
import com.squareup.moshi.JsonWriter
|
import com.squareup.moshi.JsonWriter
|
||||||
import org.joda.time.DateTimeZone
|
|
||||||
import org.joda.time.LocalDateTime
|
|
||||||
|
|
||||||
class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
||||||
|
|
||||||
|
@ -47,8 +45,7 @@ class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
||||||
with(item) {
|
with(item) {
|
||||||
when (reader.selectName(NAMES)) {
|
when (reader.selectName(NAMES)) {
|
||||||
0 -> remoteId = reader.nextNonEmptyString()
|
0 -> remoteId = reader.nextNonEmptyString()
|
||||||
1 -> pubDate = LocalDateTime(reader.nextLong() * 1000L,
|
1 -> pubDate = DateUtils.fromEpochSeconds(reader.nextLong())
|
||||||
DateTimeZone.getDefault())
|
|
||||||
2 -> title = reader.nextNonEmptyString()
|
2 -> title = reader.nextNonEmptyString()
|
||||||
3 -> content = getContent(reader)
|
3 -> content = getContent(reader)
|
||||||
4 -> link = getLink(reader)
|
4 -> link = getLink(reader)
|
||||||
|
@ -108,7 +105,6 @@ class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
||||||
when (reader.nextString()) {
|
when (reader.nextString()) {
|
||||||
GOOGLE_READ -> item.isRead = true
|
GOOGLE_READ -> item.isRead = true
|
||||||
GOOGLE_STARRED -> item.isStarred = true
|
GOOGLE_STARRED -> item.isStarred = true
|
||||||
else -> reader.skipValue()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +0,0 @@
|
||||||
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)
|
|
|
@ -1,300 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,58 +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 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/"
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
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(),
|
|
||||||
)
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
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)
|
|
@ -0,0 +1,153 @@
|
||||||
|
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 {
|
||||||
|
listOf(
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
).awaitAll()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
listOf(
|
||||||
|
async { setItemsReadState(syncData) },
|
||||||
|
async { setItemsStarState(syncData) },
|
||||||
|
).awaitAll()
|
||||||
|
|
||||||
|
DataSourceResult().apply {
|
||||||
|
listOf(
|
||||||
|
async { folders = getFolders() },
|
||||||
|
async { feeds = getFeeds() },
|
||||||
|
async { items = getNewItems(syncData.lastModified, ItemQueryType.ALL) }
|
||||||
|
).awaitAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
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/"
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
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(),
|
||||||
|
)
|
|
@ -11,7 +11,7 @@ import com.squareup.moshi.JsonReader
|
||||||
import com.squareup.moshi.ToJson
|
import com.squareup.moshi.ToJson
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
|
|
||||||
class NextNewsFeedsAdapter {
|
class NextcloudNewsFeedsAdapter {
|
||||||
|
|
||||||
@ToJson
|
@ToJson
|
||||||
fun toJson(feeds: List<Feed>): String = ""
|
fun toJson(feeds: List<Feed>): String = ""
|
|
@ -8,7 +8,7 @@ import com.squareup.moshi.FromJson
|
||||||
import com.squareup.moshi.JsonReader
|
import com.squareup.moshi.JsonReader
|
||||||
import com.squareup.moshi.ToJson
|
import com.squareup.moshi.ToJson
|
||||||
|
|
||||||
class NextNewsFoldersAdapter {
|
class NextcloudNewsFoldersAdapter {
|
||||||
|
|
||||||
@ToJson
|
@ToJson
|
||||||
fun toJson(folders: List<Folder>): String = ""
|
fun toJson(folders: List<Folder>): String = ""
|
|
@ -1,18 +1,17 @@
|
||||||
package com.readrops.api.services.nextcloudnews.adapters
|
package com.readrops.api.services.nextcloudnews.adapters
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import com.readrops.db.entities.Item
|
|
||||||
import com.readrops.api.utils.ApiUtils
|
import com.readrops.api.utils.ApiUtils
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||||
import com.readrops.api.utils.extensions.nextNullableString
|
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.JsonAdapter
|
||||||
import com.squareup.moshi.JsonReader
|
import com.squareup.moshi.JsonReader
|
||||||
import com.squareup.moshi.JsonWriter
|
import com.squareup.moshi.JsonWriter
|
||||||
import org.joda.time.DateTimeZone
|
|
||||||
import org.joda.time.LocalDateTime
|
|
||||||
|
|
||||||
class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
|
class NextcloudNewsItemsAdapter : JsonAdapter<List<Item>>() {
|
||||||
|
|
||||||
override fun toJson(writer: JsonWriter, value: List<Item>?) {
|
override fun toJson(writer: JsonWriter, value: List<Item>?) {
|
||||||
// no need of this
|
// no need of this
|
||||||
|
@ -42,14 +41,13 @@ class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
|
||||||
1 -> link = reader.nextNullableString()
|
1 -> link = reader.nextNullableString()
|
||||||
2 -> title = reader.nextNonEmptyString()
|
2 -> title = reader.nextNonEmptyString()
|
||||||
3 -> author = reader.nextNullableString()
|
3 -> author = reader.nextNullableString()
|
||||||
4 -> pubDate = LocalDateTime(reader.nextLong() * 1000L, DateTimeZone.getDefault())
|
4 -> pubDate = DateUtils.fromEpochSeconds(reader.nextLong())
|
||||||
5 -> content = reader.nextNullableString()
|
5 -> content = reader.nextNullableString()
|
||||||
6 -> enclosureMime = reader.nextNullableString()
|
6 -> enclosureMime = reader.nextNullableString()
|
||||||
7 -> enclosureLink = reader.nextNullableString()
|
7 -> enclosureLink = reader.nextNullableString()
|
||||||
8 -> feedRemoteId = reader.nextInt().toString()
|
8 -> feedRemoteId = reader.nextInt().toString()
|
||||||
9 -> isRead = !reader.nextBoolean() // the negation is important here
|
9 -> isRead = !reader.nextBoolean() // the negation is important here
|
||||||
10 -> isStarred = reader.nextBoolean()
|
10 -> isStarred = reader.nextBoolean()
|
||||||
11 -> guid = reader.nextNullableString()
|
|
||||||
else -> reader.skipValue()
|
else -> reader.skipValue()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -73,6 +71,6 @@ class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title", "author",
|
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title", "author",
|
||||||
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred", "guidHash")
|
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred")
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -6,7 +6,7 @@ import com.readrops.api.localfeed.XmlAdapter
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.api.utils.extensions.nonNullText
|
import com.readrops.api.utils.extensions.nonNullText
|
||||||
|
|
||||||
class NextNewsUserAdapter : XmlAdapter<String> {
|
class NextcloudNewsUserAdapter : XmlAdapter<String> {
|
||||||
|
|
||||||
override fun fromXml(konsumer: Konsumer): String {
|
override fun fromXml(konsumer: Konsumer): String {
|
||||||
var displayName: String? = null
|
var displayName: String? = null
|
|
@ -18,6 +18,8 @@ object ApiUtils {
|
||||||
const val HTTP_NOT_FOUND = 404
|
const val HTTP_NOT_FOUND = 404
|
||||||
const val HTTP_CONFLICT = 409
|
const val HTTP_CONFLICT = 409
|
||||||
|
|
||||||
|
val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml")
|
||||||
|
|
||||||
private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)"
|
private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)"
|
||||||
|
|
||||||
fun isMimeImage(type: String): Boolean =
|
fun isMimeImage(type: String): Boolean =
|
||||||
|
|
|
@ -1,67 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.readrops.api.utils
|
||||||
|
|
||||||
|
import com.readrops.api.utils.exceptions.HttpException
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
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) {
|
||||||
|
throw HttpException(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,88 @@
|
||||||
|
package com.readrops.api.utils
|
||||||
|
|
||||||
|
import android.nfc.FormatException
|
||||||
|
import com.readrops.api.localfeed.LocalRSSHelper
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.jsoup.Jsoup
|
||||||
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
|
data class ParsingResult(
|
||||||
|
val url: String,
|
||||||
|
val label: String?,
|
||||||
|
)
|
||||||
|
|
||||||
|
object HtmlParser {
|
||||||
|
|
||||||
|
suspend fun getFaviconLink(url: String, client: OkHttpClient): String? {
|
||||||
|
val document = getHTMLHeadFromUrl(url, client)
|
||||||
|
val elements = document.select("link")
|
||||||
|
|
||||||
|
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"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -0,0 +1,14 @@
|
||||||
|
package com.readrops.api.utils.exceptions
|
||||||
|
|
||||||
|
import okhttp3.Response
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
|
||||||
|
class HttpException(val response: Response) : IOException() {
|
||||||
|
|
||||||
|
val code: Int
|
||||||
|
get() = response.code
|
||||||
|
|
||||||
|
override val message: String
|
||||||
|
get() = "HTTP " + response.code + " " + response.message
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
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.enqueueStream(stream: InputStream) {
|
||||||
|
enqueue(MockResponse()
|
||||||
|
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||||
|
.setBody(Buffer().readFrom(stream)))
|
||||||
|
}
|
|
@ -5,6 +5,7 @@ import com.readrops.api.TestUtils
|
||||||
import com.readrops.api.apiModule
|
import com.readrops.api.apiModule
|
||||||
import com.readrops.api.utils.ApiUtils
|
import com.readrops.api.utils.ApiUtils
|
||||||
import com.readrops.api.utils.AuthInterceptor
|
import com.readrops.api.utils.AuthInterceptor
|
||||||
|
import com.readrops.api.utils.exceptions.HttpException
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.api.utils.exceptions.UnknownFormatException
|
import com.readrops.api.utils.exceptions.UnknownFormatException
|
||||||
import junit.framework.TestCase.*
|
import junit.framework.TestCase.*
|
||||||
|
@ -149,7 +150,7 @@ class LocalRSSDataSourceTest : KoinTest {
|
||||||
assertNull(pair)
|
assertNull(pair)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test(expected = NetworkErrorException::class)
|
@Test(expected = HttpException::class)
|
||||||
fun response404Test() {
|
fun response404Test() {
|
||||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
|
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
|
||||||
|
|
||||||
|
|
|
@ -2,14 +2,13 @@ package com.readrops.api.localfeed.atom
|
||||||
|
|
||||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||||
import com.readrops.api.TestUtils
|
import com.readrops.api.TestUtils
|
||||||
import com.readrops.api.utils.DateUtils
|
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
|
import com.readrops.db.util.DateUtils
|
||||||
import junit.framework.TestCase
|
import junit.framework.TestCase
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import org.junit.Assert.assertThrows
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.lang.Exception
|
|
||||||
|
|
||||||
class ATOMAdapterTest {
|
class ATOMAdapterTest {
|
||||||
|
|
||||||
|
@ -37,7 +36,7 @@ class ATOMAdapterTest {
|
||||||
assertEquals(pubDate, DateUtils.parse("2020-09-06T21:09:59Z"))
|
assertEquals(pubDate, DateUtils.parse("2020-09-06T21:09:59Z"))
|
||||||
assertEquals(author, "Shinokuni")
|
assertEquals(author, "Shinokuni")
|
||||||
assertEquals(description, "Summary")
|
assertEquals(description, "Summary")
|
||||||
assertEquals(guid, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
|
assertEquals(remoteId, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
|
||||||
TestCase.assertNotNull(content)
|
TestCase.assertNotNull(content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
package com.readrops.api.localfeed.json
|
package com.readrops.api.localfeed.json
|
||||||
|
|
||||||
import com.readrops.api.TestUtils
|
import com.readrops.api.TestUtils
|
||||||
import com.readrops.api.utils.DateUtils
|
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.db.entities.Feed
|
import com.readrops.db.entities.Feed
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
|
import com.readrops.db.util.DateUtils
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import junit.framework.TestCase
|
import junit.framework.TestCase
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import junit.framework.TestCase.assertTrue
|
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import org.junit.Assert.assertThrows
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
@ -40,7 +39,7 @@ class JSONFeedAdapterTest {
|
||||||
|
|
||||||
with(items[0]) {
|
with(items[0]) {
|
||||||
assertEquals(items.size, 10)
|
assertEquals(items.size, 10)
|
||||||
assertEquals(guid, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
|
assertEquals(remoteId, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
|
||||||
assertEquals(title, "Acorn and 10.13")
|
assertEquals(title, "Acorn and 10.13")
|
||||||
assertEquals(link, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
|
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"))
|
assertEquals(pubDate, DateUtils.parse("2017-09-25T14:27:27-07:00"))
|
||||||
|
|
|
@ -2,8 +2,8 @@ package com.readrops.api.localfeed.rss1
|
||||||
|
|
||||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||||
import com.readrops.api.TestUtils
|
import com.readrops.api.TestUtils
|
||||||
import com.readrops.api.utils.DateUtils
|
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
|
import com.readrops.db.util.DateUtils
|
||||||
import junit.framework.Assert.assertEquals
|
import junit.framework.Assert.assertEquals
|
||||||
import junit.framework.Assert.assertNotNull
|
import junit.framework.Assert.assertNotNull
|
||||||
import junit.framework.TestCase
|
import junit.framework.TestCase
|
||||||
|
@ -35,7 +35,7 @@ class RSS1AdapterTest {
|
||||||
assertEquals(title, "Google Expands its Flutter Development Kit To Windows Apps")
|
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-" +
|
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")
|
"its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&utm_medium=feed")
|
||||||
assertEquals(guid!!.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" +
|
assertEquals(remoteId!!.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")
|
"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(pubDate, DateUtils.parse("2020-09-23T16:15:00+00:00"))
|
||||||
assertEquals(author, "msmash")
|
assertEquals(author, "msmash")
|
||||||
|
|
|
@ -2,8 +2,8 @@ package com.readrops.api.localfeed.rss2
|
||||||
|
|
||||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||||
import com.readrops.api.TestUtils
|
import com.readrops.api.TestUtils
|
||||||
import com.readrops.api.utils.DateUtils
|
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
|
import com.readrops.db.util.DateUtils
|
||||||
import junit.framework.TestCase
|
import junit.framework.TestCase
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import junit.framework.TestCase.assertTrue
|
import junit.framework.TestCase.assertTrue
|
||||||
|
@ -36,7 +36,7 @@ class RSS2AdapterTest {
|
||||||
assertEquals(pubDate, DateUtils.parse("Tue, 25 Aug 2020 17:15:49 +0000"))
|
assertEquals(pubDate, DateUtils.parse("Tue, 25 Aug 2020 17:15:49 +0000"))
|
||||||
assertEquals(author, "Author 1")
|
assertEquals(author, "Author 1")
|
||||||
assertEquals(description, "<a href=\"https://news.ycombinator.com/item?id=24273602\">Comments</a>")
|
assertEquals(description, "<a href=\"https://news.ycombinator.com/item?id=24273602\">Comments</a>")
|
||||||
assertEquals(guid, "https://www.bbc.com/news/world-africa-53887947")
|
assertEquals(remoteId, "https://www.bbc.com/news/world-africa-53887947")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +55,7 @@ class RSS2AdapterTest {
|
||||||
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_other_namespaces.xml")
|
val stream = TestUtils.loadResource("localfeed/rss2/rss_items_other_namespaces.xml")
|
||||||
val item = adapter.fromXml(stream.konsumeXml()).second[0]
|
val item = adapter.fromXml(stream.konsumeXml()).second[0]
|
||||||
|
|
||||||
assertEquals(item.guid, "guid")
|
assertEquals(item.remoteId, "guid")
|
||||||
assertEquals(item.author, "creator 1, creator 2, creator 3, creator 4")
|
assertEquals(item.author, "creator 1, creator 2, creator 3, creator 4")
|
||||||
assertEquals(item.pubDate, DateUtils.parse("2020-08-05T14:03:48Z"))
|
assertEquals(item.pubDate, DateUtils.parse("2020-08-05T14:03:48Z"))
|
||||||
assertEquals(item.content, "content:encoded")
|
assertEquals(item.content, "content:encoded")
|
||||||
|
|
|
@ -4,8 +4,8 @@ import com.readrops.api.TestUtils
|
||||||
import com.readrops.api.utils.exceptions.ParseException
|
import com.readrops.api.utils.exceptions.ParseException
|
||||||
import com.readrops.db.entities.Feed
|
import com.readrops.db.entities.Feed
|
||||||
import com.readrops.db.entities.Folder
|
import com.readrops.db.entities.Folder
|
||||||
import io.reactivex.schedulers.Schedulers
|
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.io.FileOutputStream
|
import java.io.FileOutputStream
|
||||||
|
@ -13,83 +13,85 @@ import java.io.FileOutputStream
|
||||||
class OPMLParserTest {
|
class OPMLParserTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun readOpmlTest() {
|
fun readOpmlTest() = runTest {
|
||||||
val stream = TestUtils.loadResource("opml/subscriptions.opml")
|
val stream = TestUtils.loadResource("opml/subscriptions.opml")
|
||||||
|
val foldersAndFeeds = OPMLParser.read(stream)
|
||||||
|
|
||||||
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
|
assertEquals(foldersAndFeeds.size, 6)
|
||||||
|
|
||||||
OPMLParser.read(stream)
|
assertEquals(foldersAndFeeds[Folder(name = "Folder 1")]?.size, 2)
|
||||||
.observeOn(Schedulers.trampoline())
|
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 1")]?.size, 4)
|
||||||
.subscribeOn(Schedulers.trampoline())
|
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 2")]?.size, 1)
|
||||||
.subscribe { result -> foldersAndFeeds = result }
|
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 1")]?.size, 2)
|
||||||
|
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 2")]?.size, 0)
|
||||||
assertEquals(foldersAndFeeds?.size, 6)
|
assertEquals(foldersAndFeeds[null]?.size, 2)
|
||||||
|
|
||||||
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()
|
stream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun readLiteSubscriptionsTest() {
|
fun readLiteSubscriptionsTest() = runTest {
|
||||||
val stream = TestUtils.loadResource("opml/lite_subscriptions.opml")
|
val stream = TestUtils.loadResource("opml/lite_subscriptions.opml")
|
||||||
|
|
||||||
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
|
val foldersAndFeeds = OPMLParser.read(stream)
|
||||||
|
|
||||||
OPMLParser.read(stream)
|
assertEquals(foldersAndFeeds.values.first().size, 2)
|
||||||
.subscribe { result -> foldersAndFeeds = result }
|
assertEquals(
|
||||||
|
foldersAndFeeds.values.first().first().url,
|
||||||
assertEquals(foldersAndFeeds?.values?.first()?.size, 2)
|
"http://www.theverge.com/rss/index.xml"
|
||||||
assertEquals(foldersAndFeeds?.values?.first()?.first()?.url, "http://www.theverge.com/rss/index.xml")
|
)
|
||||||
assertEquals(foldersAndFeeds?.values?.first()?.get(1)?.url, "https://techcrunch.com/feed/")
|
assertEquals(foldersAndFeeds.values.first()[1].url, "https://techcrunch.com/feed/")
|
||||||
|
|
||||||
stream.close()
|
stream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test(expected = ParseException::class)
|
||||||
fun opmlVersionTest() {
|
fun opmlVersionTest() = runTest {
|
||||||
val stream = TestUtils.loadResource("opml/wrong_version.opml")
|
val stream = TestUtils.loadResource("opml/wrong_version.opml")
|
||||||
|
|
||||||
OPMLParser.read(stream)
|
OPMLParser.read(stream)
|
||||||
.test()
|
|
||||||
.assertError(ParseException::class.java)
|
|
||||||
|
|
||||||
stream.close()
|
stream.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun writeOpmlTest() {
|
fun writeOpmlTest() = runTest {
|
||||||
val file = File("subscriptions.opml")
|
val file = File("subscriptions.opml")
|
||||||
val outputStream = FileOutputStream(file)
|
val outputStream = FileOutputStream(file)
|
||||||
|
|
||||||
val foldersAndFeeds: Map<Folder?, List<Feed>> = HashMap<Folder?, List<Feed>>().apply {
|
val foldersAndFeeds: Map<Folder?, List<Feed>> = HashMap<Folder?, List<Feed>>().apply {
|
||||||
put(null, listOf(Feed(name = "Feed1", url = "https://feed1.com"),
|
put(
|
||||||
Feed(name = "Feed2", url = "https://feed2.com")))
|
null, listOf(
|
||||||
|
Feed(name = "Feed1", url = "https://feed1.com"),
|
||||||
|
Feed(name = "Feed2", url = "https://feed2.com")
|
||||||
|
)
|
||||||
|
)
|
||||||
put(Folder(name = "Folder1"), listOf())
|
put(Folder(name = "Folder1"), listOf())
|
||||||
put(Folder(name = "Folder2"), listOf(Feed(name = "Feed3", url = "https://feed3.com"),
|
put(
|
||||||
Feed(name = "Feed4", url ="https://feed4.com")))
|
Folder(name = "Folder2"), listOf(
|
||||||
|
Feed(name = "Feed3", url = "https://feed3.com"),
|
||||||
|
Feed(name = "Feed4", url = "https://feed4.com")
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
OPMLParser.write(foldersAndFeeds, outputStream)
|
OPMLParser.write(foldersAndFeeds, outputStream)
|
||||||
.subscribeOn(Schedulers.trampoline())
|
|
||||||
.subscribe()
|
|
||||||
|
|
||||||
outputStream.flush()
|
outputStream.flush()
|
||||||
outputStream.close()
|
outputStream.close()
|
||||||
|
|
||||||
val inputStream = file.inputStream()
|
val inputStream = file.inputStream()
|
||||||
var foldersAndFeeds2: Map<Folder?, List<Feed>>? = null
|
val foldersAndFeeds2 = OPMLParser.read(inputStream)
|
||||||
OPMLParser.read(inputStream).subscribe { result -> foldersAndFeeds2 = result }
|
|
||||||
|
|
||||||
assertEquals(foldersAndFeeds.size, foldersAndFeeds2?.size)
|
assertEquals(foldersAndFeeds.size, foldersAndFeeds2.size)
|
||||||
assertEquals(foldersAndFeeds[Folder(name = "Folder1")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder1"))?.size)
|
assertEquals(
|
||||||
assertEquals(foldersAndFeeds[Folder(name = "Folder2")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder2"))?.size)
|
foldersAndFeeds[Folder(name = "Folder1")]?.size,
|
||||||
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2?.get(null)?.size)
|
foldersAndFeeds2[Folder(name = "Folder1")]?.size
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
foldersAndFeeds[Folder(name = "Folder2")]?.size,
|
||||||
|
foldersAndFeeds2[Folder(name = "Folder2")]?.size
|
||||||
|
)
|
||||||
|
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2[null]?.size)
|
||||||
|
|
||||||
inputStream.close()
|
inputStream.close()
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
package com.readrops.api.services
|
package com.readrops.api.services
|
||||||
|
|
||||||
import com.readrops.api.services.freshrss.FreshRSSCredentials
|
import com.readrops.api.services.freshrss.FreshRSSCredentials
|
||||||
import com.readrops.api.services.nextcloudnews.NextNewsCredentials
|
import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ class CredentialsTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun nextcloudNewsCredentialsTest() {
|
fun nextcloudNewsCredentialsTest() {
|
||||||
val credentials = NextNewsCredentials("login", "password", "https://freshrss.org")
|
val credentials = NextcloudNewsCredentials("login", "password", "https://freshrss.org")
|
||||||
|
|
||||||
assertEquals(credentials.authorization!!, "Basic bG9naW46cGFzc3dvcmQ=")
|
assertEquals(credentials.authorization!!, "Basic bG9naW46cGFzc3dvcmQ=")
|
||||||
assertEquals(credentials.url, "https://freshrss.org")
|
assertEquals(credentials.url, "https://freshrss.org")
|
||||||
|
|
|
@ -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: FreshRSSDataSource
|
||||||
|
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(FreshRSSService::class.java)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun before() {
|
||||||
|
mockServer.start(8080)
|
||||||
|
freshRSSDataSource = FreshRSSDataSource(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(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_STARRED), 100, 21343321321321)
|
||||||
|
assertTrue { items.size == 2 }
|
||||||
|
|
||||||
|
val request = mockServer.takeRequest()
|
||||||
|
|
||||||
|
with(request.requestUrl!!) {
|
||||||
|
assertEquals(listOf(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.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(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_READING_LIST, 100)
|
||||||
|
assertTrue { ids.size == 5 }
|
||||||
|
|
||||||
|
val request = mockServer.takeRequest()
|
||||||
|
with(request.requestUrl!!) {
|
||||||
|
assertEquals(FreshRSSDataSource.GOOGLE_READ, queryParameter("xt"))
|
||||||
|
assertEquals(FreshRSSDataSource.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("${FreshRSSDataSource.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("${FreshRSSDataSource.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("${FreshRSSDataSource.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("${FreshRSSDataSource.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("${FreshRSSDataSource.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") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,12 +2,12 @@ package com.readrops.api.services.freshrss.adapters
|
||||||
|
|
||||||
import com.readrops.api.TestUtils
|
import com.readrops.api.TestUtils
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
|
import com.readrops.db.util.DateUtils
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import junit.framework.TestCase.assertNotNull
|
import junit.framework.TestCase.assertNotNull
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import org.joda.time.LocalDateTime
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class FreshRSSItemsAdapterTest {
|
class FreshRSSItemsAdapterTest {
|
||||||
|
@ -29,7 +29,7 @@ class FreshRSSItemsAdapterTest {
|
||||||
assertNotNull(content)
|
assertNotNull(content)
|
||||||
assertEquals(link, "http://feedproxy.google.com/~r/d0od/~3/4Zk-fncSuek/adwaita-borderless-theme-in-development-gnome-41")
|
assertEquals(link, "http://feedproxy.google.com/~r/d0od/~3/4Zk-fncSuek/adwaita-borderless-theme-in-development-gnome-41")
|
||||||
assertEquals(author, "Joey Sneddon")
|
assertEquals(author, "Joey Sneddon")
|
||||||
assertEquals(pubDate, LocalDateTime(1625234040 * 1000L))
|
assertEquals(pubDate, DateUtils.fromEpochSeconds(1625234040))
|
||||||
assertEquals(isRead, false)
|
assertEquals(isRead, false)
|
||||||
assertEquals(isStarred, false)
|
assertEquals(isStarred, false)
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,284 @@
|
||||||
|
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.enqueueStream
|
||||||
|
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.MockWebServer
|
||||||
|
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.enqueueStream(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.enqueueStream(stream)
|
||||||
|
|
||||||
|
val folders = nextcloudNewsDataSource.getFolders()
|
||||||
|
assertTrue { folders.size == 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun feedsTest() = runTest {
|
||||||
|
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json")
|
||||||
|
mockServer.enqueueStream(stream)
|
||||||
|
|
||||||
|
val feeds = nextcloudNewsDataSource.getFeeds()
|
||||||
|
assertTrue { feeds.size == 3 }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun itemsTest() = runTest {
|
||||||
|
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
|
||||||
|
mockServer.enqueueStream(stream)
|
||||||
|
|
||||||
|
val items = nextcloudNewsDataSource.getItems(NextcloudNewsDataSource.ItemQueryType.ALL.value, false, 10)
|
||||||
|
val request = mockServer.takeRequest()
|
||||||
|
|
||||||
|
assertTrue { items.size == 3 }
|
||||||
|
with(request.requestUrl!!) {
|
||||||
|
assertEquals("3", queryParameter("type"))
|
||||||
|
assertEquals("false", queryParameter("getRead"))
|
||||||
|
assertEquals("10", queryParameter("batchSize"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun newItemsTest() = runTest {
|
||||||
|
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
|
||||||
|
mockServer.enqueueStream(stream)
|
||||||
|
|
||||||
|
val items =
|
||||||
|
nextcloudNewsDataSource.getNewItems(1512, NextcloudNewsDataSource.ItemQueryType.ALL)
|
||||||
|
val request = mockServer.takeRequest()
|
||||||
|
|
||||||
|
assertTrue { items.size == 3 }
|
||||||
|
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.enqueueStream(stream)
|
||||||
|
|
||||||
|
val feeds = nextcloudNewsDataSource.createFeed("https://news.ycombinator.com/rss", null)
|
||||||
|
val request = mockServer.takeRequest()
|
||||||
|
|
||||||
|
assertTrue { feeds.isNotEmpty() }
|
||||||
|
with(request.requestUrl!!) {
|
||||||
|
assertEquals("https://news.ycombinator.com/rss", queryParameter("url"))
|
||||||
|
assertEquals(null, 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.enqueueStream(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"])
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,10 +9,10 @@ import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class NextNewsFeedsAdapterTest {
|
class NextcloudNewsFeedsAdapterTest {
|
||||||
|
|
||||||
private val adapter = Moshi.Builder()
|
private val adapter = Moshi.Builder()
|
||||||
.add(NextNewsFeedsAdapter())
|
.add(NextcloudNewsFeedsAdapter())
|
||||||
.build()
|
.build()
|
||||||
.adapter<List<Feed>>(Types.newParameterizedType(List::class.java, Feed::class.java))
|
.adapter<List<Feed>>(Types.newParameterizedType(List::class.java, Feed::class.java))
|
||||||
|
|
|
@ -10,10 +10,10 @@ import okio.Buffer
|
||||||
import org.junit.Assert.assertThrows
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class NextNewsFoldersAdapterTest {
|
class NextcloudNewsFoldersAdapterTest {
|
||||||
|
|
||||||
private val adapter = Moshi.Builder()
|
private val adapter = Moshi.Builder()
|
||||||
.add(NextNewsFoldersAdapter())
|
.add(NextcloudNewsFoldersAdapter())
|
||||||
.build()
|
.build()
|
||||||
.adapter<List<Folder>>(Types.newParameterizedType(List::class.java, Folder::class.java))
|
.adapter<List<Folder>>(Types.newParameterizedType(List::class.java, Folder::class.java))
|
||||||
|
|
|
@ -2,17 +2,17 @@ package com.readrops.api.services.nextcloudnews.adapters
|
||||||
|
|
||||||
import com.readrops.api.TestUtils
|
import com.readrops.api.TestUtils
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
|
import com.readrops.db.util.DateUtils
|
||||||
import com.squareup.moshi.Moshi
|
import com.squareup.moshi.Moshi
|
||||||
import com.squareup.moshi.Types
|
import com.squareup.moshi.Types
|
||||||
import junit.framework.TestCase.assertEquals
|
import junit.framework.TestCase.assertEquals
|
||||||
import okio.Buffer
|
import okio.Buffer
|
||||||
import org.joda.time.LocalDateTime
|
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class NextNewsItemsAdapterTest {
|
class NextcloudNewsItemsAdapterTest {
|
||||||
|
|
||||||
private val adapter = Moshi.Builder()
|
private val adapter = Moshi.Builder()
|
||||||
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter())
|
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextcloudNewsItemsAdapter())
|
||||||
.build()
|
.build()
|
||||||
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
|
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
|
||||||
|
|
||||||
|
@ -25,7 +25,6 @@ class NextNewsItemsAdapterTest {
|
||||||
|
|
||||||
with(item) {
|
with(item) {
|
||||||
assertEquals(remoteId, "3443")
|
assertEquals(remoteId, "3443")
|
||||||
assertEquals(guid, "3059047a572cd9cd5d0bf645faffd077")
|
|
||||||
assertEquals(link, "http://grulja.wordpress.com/2013/04/29/plasma-nm-after-the-solid-sprint/")
|
assertEquals(link, "http://grulja.wordpress.com/2013/04/29/plasma-nm-after-the-solid-sprint/")
|
||||||
assertEquals(title, "Plasma-nm after the solid sprint")
|
assertEquals(title, "Plasma-nm after the solid sprint")
|
||||||
assertEquals(author, "Jan Grulich (grulja)")
|
assertEquals(author, "Jan Grulich (grulja)")
|
||||||
|
@ -33,7 +32,7 @@ class NextNewsItemsAdapterTest {
|
||||||
assertEquals(feedRemoteId, "67")
|
assertEquals(feedRemoteId, "67")
|
||||||
assertEquals(isRead, false)
|
assertEquals(isRead, false)
|
||||||
assertEquals(isStarred, false)
|
assertEquals(isStarred, false)
|
||||||
assertEquals(pubDate, LocalDateTime(1367270544000))
|
assertEquals(pubDate, DateUtils.fromEpochSeconds(1367270544))
|
||||||
assertEquals(imageLink, null)
|
assertEquals(imageLink, null)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,9 @@ import com.readrops.api.TestUtils
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
class NextNewsUserAdapterTest {
|
class NextcloudNewsUserAdapterTest {
|
||||||
|
|
||||||
private val adapter = NextNewsUserAdapter()
|
private val adapter = NextcloudNewsUserAdapter()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun validXmlTest() {
|
fun validXmlTest() {
|
|
@ -1,58 +0,0 @@
|
||||||
package com.readrops.api.utils;
|
|
||||||
|
|
||||||
import org.joda.time.LocalDateTime;
|
|
||||||
import org.junit.Test;
|
|
||||||
|
|
||||||
import static org.junit.Assert.assertEquals;
|
|
||||||
|
|
||||||
public class DateUtilsTest {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void rssDateTest() {
|
|
||||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
|
||||||
|
|
||||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46 GMT")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void rssDate2Test() {
|
|
||||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
|
||||||
|
|
||||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46 +0000")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void rssDate3Test() {
|
|
||||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
|
||||||
|
|
||||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void atomJsonDateTest() {
|
|
||||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
|
||||||
|
|
||||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("2019-01-04T22:21:46+00:00")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void atomJsonDate2Test() {
|
|
||||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
|
||||||
|
|
||||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("2019-01-04T22:21:46-0000")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void isoPatternTest() {
|
|
||||||
LocalDateTime dateTime = new LocalDateTime(2020, 6, 30, 11, 39, 37, 206);
|
|
||||||
|
|
||||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("2020-06-30T11:39:37.206-07:00")));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
public void edtPatternTest() {
|
|
||||||
LocalDateTime dateTime = new LocalDateTime(2020, 7, 17, 16, 30, 0);
|
|
||||||
|
|
||||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 17 Jul 2020 16:30:00 EDT")));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,39 @@
|
||||||
|
package com.readrops.api.utils
|
||||||
|
|
||||||
|
import com.readrops.api.utils.exceptions.HttpException
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
|
||||||
|
class ErrorInterceptorTest {
|
||||||
|
|
||||||
|
private val interceptor = ErrorInterceptor()
|
||||||
|
private val server = MockWebServer()
|
||||||
|
private lateinit var client: OkHttpClient
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun before() {
|
||||||
|
client = OkHttpClient.Builder()
|
||||||
|
.addInterceptor(interceptor)
|
||||||
|
.build()
|
||||||
|
server.start(8080)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = HttpException::class)
|
||||||
|
fun interceptorTest() {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
|
||||||
|
|
||||||
|
client.newCall(Request.Builder().url(server.url("/url")).build()).execute()
|
||||||
|
//val request = server.takeRequest()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,124 @@
|
||||||
|
package com.readrops.api.utils
|
||||||
|
|
||||||
|
import android.nfc.FormatException
|
||||||
|
import com.readrops.api.TestUtils
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okio.Buffer
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.KoinTestRule
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class HtmlParserTest : KoinTest {
|
||||||
|
|
||||||
|
private val mockServer = MockWebServer()
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val koinTestRule = KoinTestRule.create {
|
||||||
|
modules(module {
|
||||||
|
single {
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.callTimeout(1, TimeUnit.MINUTES)
|
||||||
|
.readTimeout(1, TimeUnit.HOURS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun before() {
|
||||||
|
mockServer.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun after() {
|
||||||
|
mockServer.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getFeedLinkTest() {
|
||||||
|
val stream = TestUtils.loadResource("utils/file.html")
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||||
|
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||||
|
.setBody(Buffer().readFrom(stream))
|
||||||
|
)
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
val result =
|
||||||
|
HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
|
||||||
|
|
||||||
|
assertTrue { result.size == 1 }
|
||||||
|
assertTrue { result.first().url.endsWith("/rss") }
|
||||||
|
assertEquals("RSS", result.first().label)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = FormatException::class)
|
||||||
|
fun getFeedLinkWithoutHeadTest() {
|
||||||
|
val stream = TestUtils.loadResource("utils/file_without_head.html")
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||||
|
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||||
|
.setBody(Buffer().readFrom(stream))
|
||||||
|
)
|
||||||
|
|
||||||
|
runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test(expected = FormatException::class)
|
||||||
|
fun getFeedLinkNoHtmlFileTest() {
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||||
|
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/rss+xml"))
|
||||||
|
|
||||||
|
|
||||||
|
runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getFaviconLinkTest() {
|
||||||
|
val stream = TestUtils.loadResource("utils/file.html")
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||||
|
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||||
|
.setBody(Buffer().readFrom(stream))
|
||||||
|
)
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
|
||||||
|
|
||||||
|
assertTrue { result!!.contains("favicon.ico") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getFaviconLinkWithoutHeadTest() {
|
||||||
|
val stream = TestUtils.loadResource("utils/file_without_icon.html")
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||||
|
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||||
|
.setBody(Buffer().readFrom(stream))
|
||||||
|
)
|
||||||
|
|
||||||
|
runBlocking {
|
||||||
|
val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
|
||||||
|
|
||||||
|
assertNull(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -18,7 +18,10 @@
|
||||||
],
|
],
|
||||||
"categories": [
|
"categories": [
|
||||||
"user/-/state/com.google/reading-list",
|
"user/-/state/com.google/reading-list",
|
||||||
"user/-/label/Libre"
|
"user/-/label/Libre",
|
||||||
|
"category1",
|
||||||
|
"category2",
|
||||||
|
"category3"
|
||||||
],
|
],
|
||||||
"origin": {
|
"origin": {
|
||||||
"streamId": "feed/15",
|
"streamId": "feed/15",
|
||||||
|
@ -44,7 +47,10 @@
|
||||||
"user/-/state/com.google/reading-list",
|
"user/-/state/com.google/reading-list",
|
||||||
"user/-/label/Libre",
|
"user/-/label/Libre",
|
||||||
"user/-/state/com.google/starred",
|
"user/-/state/com.google/starred",
|
||||||
"user/-/state/com.google/read"
|
"user/-/state/com.google/read",
|
||||||
|
"category1",
|
||||||
|
"category2",
|
||||||
|
"category3"
|
||||||
],
|
],
|
||||||
"origin": {
|
"origin": {
|
||||||
"streamId": "feed/15",
|
"streamId": "feed/15",
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
SID=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a
|
||||||
|
LSID=null
|
||||||
|
Auth=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a
|
|
@ -0,0 +1 @@
|
||||||
|
PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg
|
|
@ -0,0 +1,601 @@
|
||||||
|
<html lang="en" op="news">
|
||||||
|
<head>
|
||||||
|
<meta name="referrer" content="origin">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" type="text/css" href="news.css?t8fsBYOw2Gz0ODjGokUo">
|
||||||
|
<link rel="shortcut icon" href="favicon.ico">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="RSS" href="rss">
|
||||||
|
<title>Hacker News</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<center>
|
||||||
|
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ff6600">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
|
||||||
|
<tr>
|
||||||
|
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
|
||||||
|
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
|
||||||
|
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
|
||||||
|
<a href="login?goto=news">login</a>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="pagespace" title="" style="height:10px"></tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr class='athing' id='36826210'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826210'href='vote?id=36826210&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&goto=news">hide</a> | <a href="item?id=36826210">3 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36813688'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36813688'href='vote?id=36813688&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&goto=news">hide</a> | <a href="item?id=36813688">1 comment</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36797650'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36797650'href='vote?id=36797650&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&goto=news">hide</a> | <a href="item?id=36797650">26 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36827034'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36827034'href='vote?id=36827034&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&goto=news">hide</a> | <a href="item?id=36827034">1 comment</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823565'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823565'href='vote?id=36823565&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple's interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&goto=news">hide</a> | <a href="item?id=36823565">20 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823605'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823605'href='vote?id=36823605&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&goto=news">hide</a> | <a href="item?id=36823605">73 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36826177'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826177'href='vote?id=36826177&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&goto=news">hide</a> | <a href="item?id=36826177">10 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824595'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824595'href='vote?id=36824595&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos – HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&goto=news">hide</a> | <a href="item?id=36824595">23 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825992'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825992'href='vote?id=36825992&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you've ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&goto=news">hide</a> | <a href="item?id=36825992">3 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825345'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825345'href='vote?id=36825345&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&goto=news">hide</a> | <a href="item?id=36825345">1 comment</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823516'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823516'href='vote?id=36823516&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&goto=news">hide</a> | <a href="item?id=36823516">66 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824450'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824450'href='vote?id=36824450&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane – Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&goto=news">hide</a> | <a href="item?id=36824450">93 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825481'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825481'href='vote?id=36825481&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&goto=news">hide</a> | <a href="item?id=36825481">15 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823375'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823375'href='vote?id=36823375&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&goto=news">hide</a> | <a href="item?id=36823375">22 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823524'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823524'href='vote?id=36823524&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe – Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&goto=news">hide</a> | <a href="item?id=36823524">73 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824607'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824607'href='vote?id=36824607&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&goto=news">hide</a> | <a href="item?id=36824607">43 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825913'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825913'href='vote?id=36825913&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine – open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&goto=news">hide</a> | <a href="item?id=36825913">24 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36797471'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36797471'href='vote?id=36797471&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&goto=news">hide</a> | <a href="item?id=36797471">73 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825204'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825204'href='vote?id=36825204&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&goto=news">hide</a> | <a href="item?id=36825204">72 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36822880'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36822880'href='vote?id=36822880&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&goto=news">hide</a> | <a href="item?id=36822880">58 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36803767'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36803767'href='vote?id=36803767&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&goto=news">hide</a> | <a href="item?id=36803767">18 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824330'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824330'href='vote?id=36824330&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&goto=news">hide</a> | <a href="item?id=36824330">11 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36826111'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826111'href='vote?id=36826111&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&goto=news">hide</a> | <a href="item?id=36826111">121 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36784114'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36784114'href='vote?id=36784114&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&goto=news">hide</a> | <a href="item?id=36784114">75 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36808566'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36808566'href='vote?id=36808566&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823723'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823723'href='vote?id=36823723&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&goto=news">hide</a> | <a href="item?id=36823723">107 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824856'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824856'href='vote?id=36824856&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&goto=news">hide</a> | <a href="item?id=36824856">55 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36822530'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36822530'href='vote?id=36822530&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&goto=news">hide</a> | <a href="item?id=36822530">24 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36783937'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36783937'href='vote?id=36783937&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&goto=news">hide</a> | <a href="item?id=36783937">57 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36826664'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826664'href='vote?id=36826664&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class="morespace" style="height:10px"></tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="s.gif" height="10" width="0">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="1">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ff6600"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<br>
|
||||||
|
<center>
|
||||||
|
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
|
||||||
|
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
|
||||||
|
</html>
|
||||||
|
|
|
@ -0,0 +1,593 @@
|
||||||
|
<html lang="en" op="news">
|
||||||
|
<body>
|
||||||
|
<center>
|
||||||
|
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ff6600">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
|
||||||
|
<tr>
|
||||||
|
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
|
||||||
|
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
|
||||||
|
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
|
||||||
|
<a href="login?goto=news">login</a>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="pagespace" title="" style="height:10px"></tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr class='athing' id='36826210'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826210'href='vote?id=36826210&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&goto=news">hide</a> | <a href="item?id=36826210">3 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36813688'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36813688'href='vote?id=36813688&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&goto=news">hide</a> | <a href="item?id=36813688">1 comment</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36797650'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36797650'href='vote?id=36797650&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&goto=news">hide</a> | <a href="item?id=36797650">26 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36827034'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36827034'href='vote?id=36827034&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&goto=news">hide</a> | <a href="item?id=36827034">1 comment</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823565'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823565'href='vote?id=36823565&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple's interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&goto=news">hide</a> | <a href="item?id=36823565">20 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823605'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823605'href='vote?id=36823605&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&goto=news">hide</a> | <a href="item?id=36823605">73 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36826177'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826177'href='vote?id=36826177&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&goto=news">hide</a> | <a href="item?id=36826177">10 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824595'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824595'href='vote?id=36824595&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos – HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&goto=news">hide</a> | <a href="item?id=36824595">23 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825992'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825992'href='vote?id=36825992&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you've ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&goto=news">hide</a> | <a href="item?id=36825992">3 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825345'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825345'href='vote?id=36825345&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&goto=news">hide</a> | <a href="item?id=36825345">1 comment</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823516'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823516'href='vote?id=36823516&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&goto=news">hide</a> | <a href="item?id=36823516">66 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824450'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824450'href='vote?id=36824450&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane – Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&goto=news">hide</a> | <a href="item?id=36824450">93 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825481'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825481'href='vote?id=36825481&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&goto=news">hide</a> | <a href="item?id=36825481">15 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823375'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823375'href='vote?id=36823375&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&goto=news">hide</a> | <a href="item?id=36823375">22 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823524'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823524'href='vote?id=36823524&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe – Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&goto=news">hide</a> | <a href="item?id=36823524">73 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824607'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824607'href='vote?id=36824607&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&goto=news">hide</a> | <a href="item?id=36824607">43 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825913'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825913'href='vote?id=36825913&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine – open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&goto=news">hide</a> | <a href="item?id=36825913">24 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36797471'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36797471'href='vote?id=36797471&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&goto=news">hide</a> | <a href="item?id=36797471">73 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825204'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825204'href='vote?id=36825204&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&goto=news">hide</a> | <a href="item?id=36825204">72 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36822880'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36822880'href='vote?id=36822880&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&goto=news">hide</a> | <a href="item?id=36822880">58 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36803767'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36803767'href='vote?id=36803767&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&goto=news">hide</a> | <a href="item?id=36803767">18 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824330'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824330'href='vote?id=36824330&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&goto=news">hide</a> | <a href="item?id=36824330">11 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36826111'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826111'href='vote?id=36826111&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&goto=news">hide</a> | <a href="item?id=36826111">121 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36784114'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36784114'href='vote?id=36784114&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&goto=news">hide</a> | <a href="item?id=36784114">75 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36808566'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36808566'href='vote?id=36808566&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823723'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823723'href='vote?id=36823723&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&goto=news">hide</a> | <a href="item?id=36823723">107 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824856'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824856'href='vote?id=36824856&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&goto=news">hide</a> | <a href="item?id=36824856">55 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36822530'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36822530'href='vote?id=36822530&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&goto=news">hide</a> | <a href="item?id=36822530">24 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36783937'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36783937'href='vote?id=36783937&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&goto=news">hide</a> | <a href="item?id=36783937">57 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36826664'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826664'href='vote?id=36826664&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class="morespace" style="height:10px"></tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="s.gif" height="10" width="0">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="1">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ff6600"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<br>
|
||||||
|
<center>
|
||||||
|
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
|
||||||
|
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
|
||||||
|
</html>
|
||||||
|
|
|
@ -0,0 +1,600 @@
|
||||||
|
<html lang="en" op="news">
|
||||||
|
<head>
|
||||||
|
<meta name="referrer" content="origin">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" type="text/css" href="news.css?t8fsBYOw2Gz0ODjGokUo">
|
||||||
|
<link rel="alternate" type="application/rss+xml" title="RSS" href="rss">
|
||||||
|
<title>Hacker News</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<center>
|
||||||
|
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ff6600">
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
|
||||||
|
<tr>
|
||||||
|
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
|
||||||
|
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
|
||||||
|
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
|
||||||
|
</td>
|
||||||
|
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
|
||||||
|
<a href="login?goto=news">login</a>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr id="pagespace" title="" style="height:10px"></tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tr class='athing' id='36826210'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826210'href='vote?id=36826210&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&goto=news">hide</a> | <a href="item?id=36826210">3 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36813688'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36813688'href='vote?id=36813688&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&goto=news">hide</a> | <a href="item?id=36813688">1 comment</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36797650'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36797650'href='vote?id=36797650&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&goto=news">hide</a> | <a href="item?id=36797650">26 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36827034'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36827034'href='vote?id=36827034&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&goto=news">hide</a> | <a href="item?id=36827034">1 comment</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823565'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823565'href='vote?id=36823565&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple's interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&goto=news">hide</a> | <a href="item?id=36823565">20 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823605'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823605'href='vote?id=36823605&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&goto=news">hide</a> | <a href="item?id=36823605">73 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36826177'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826177'href='vote?id=36826177&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&goto=news">hide</a> | <a href="item?id=36826177">10 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824595'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824595'href='vote?id=36824595&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos – HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&goto=news">hide</a> | <a href="item?id=36824595">23 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825992'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825992'href='vote?id=36825992&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you've ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&goto=news">hide</a> | <a href="item?id=36825992">3 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825345'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825345'href='vote?id=36825345&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&goto=news">hide</a> | <a href="item?id=36825345">1 comment</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823516'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823516'href='vote?id=36823516&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&goto=news">hide</a> | <a href="item?id=36823516">66 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824450'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824450'href='vote?id=36824450&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane – Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&goto=news">hide</a> | <a href="item?id=36824450">93 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825481'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825481'href='vote?id=36825481&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&goto=news">hide</a> | <a href="item?id=36825481">15 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823375'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823375'href='vote?id=36823375&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&goto=news">hide</a> | <a href="item?id=36823375">22 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823524'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823524'href='vote?id=36823524&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe – Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&goto=news">hide</a> | <a href="item?id=36823524">73 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824607'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824607'href='vote?id=36824607&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&goto=news">hide</a> | <a href="item?id=36824607">43 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825913'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825913'href='vote?id=36825913&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine – open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&goto=news">hide</a> | <a href="item?id=36825913">24 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36797471'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36797471'href='vote?id=36797471&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&goto=news">hide</a> | <a href="item?id=36797471">73 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36825204'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36825204'href='vote?id=36825204&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&goto=news">hide</a> | <a href="item?id=36825204">72 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36822880'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36822880'href='vote?id=36822880&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&goto=news">hide</a> | <a href="item?id=36822880">58 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36803767'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36803767'href='vote?id=36803767&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&goto=news">hide</a> | <a href="item?id=36803767">18 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824330'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824330'href='vote?id=36824330&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&goto=news">hide</a> | <a href="item?id=36824330">11 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36826111'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826111'href='vote?id=36826111&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&goto=news">hide</a> | <a href="item?id=36826111">121 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36784114'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36784114'href='vote?id=36784114&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&goto=news">hide</a> | <a href="item?id=36784114">75 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36808566'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36808566'href='vote?id=36808566&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36823723'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36823723'href='vote?id=36823723&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&goto=news">hide</a> | <a href="item?id=36823723">107 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36824856'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36824856'href='vote?id=36824856&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&goto=news">hide</a> | <a href="item?id=36824856">55 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36822530'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36822530'href='vote?id=36822530&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&goto=news">hide</a> | <a href="item?id=36822530">24 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36783937'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36783937'href='vote?id=36783937&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&goto=news">hide</a> | <a href="item?id=36783937">57 comments</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class='athing' id='36826664'>
|
||||||
|
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
|
||||||
|
<td valign="top" class="votelinks">
|
||||||
|
<center>
|
||||||
|
<a id='up_36826664'href='vote?id=36826664&how=up&goto=news'>
|
||||||
|
<div class='votearrow' title='upvote'></div>
|
||||||
|
</a>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class="subtext"><span class="subline">
|
||||||
|
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="spacer" style="height:5px"></tr>
|
||||||
|
<tr class="morespace" style="height:10px"></tr>
|
||||||
|
<tr>
|
||||||
|
<td colspan="2"></td>
|
||||||
|
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<img src="s.gif" height="10" width="0">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="1">
|
||||||
|
<tr>
|
||||||
|
<td bgcolor="#ff6600"></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<br>
|
||||||
|
<center>
|
||||||
|
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
|
||||||
|
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
|
||||||
|
</center>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</center>
|
||||||
|
</body>
|
||||||
|
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
|
||||||
|
</html>
|
||||||
|
|
|
@ -1 +1 @@
|
||||||
/build
|
/build
|
106
app/build.gradle
106
app/build.gradle
|
@ -1,106 +0,0 @@
|
||||||
apply plugin: 'com.android.application'
|
|
||||||
apply plugin: 'kotlin-android'
|
|
||||||
apply plugin: 'kotlin-kapt'
|
|
||||||
|
|
||||||
android {
|
|
||||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId "com.readrops.app"
|
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
|
||||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
|
||||||
|
|
||||||
versionCode 14
|
|
||||||
versionName "1.3.1"
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
}
|
|
||||||
testOptions {
|
|
||||||
unitTests.returnDefaultValues = true
|
|
||||||
}
|
|
||||||
lintOptions {
|
|
||||||
abortOnError false
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
release {
|
|
||||||
minifyEnabled true
|
|
||||||
shrinkResources true
|
|
||||||
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
|
|
||||||
debug {
|
|
||||||
minifyEnabled false
|
|
||||||
shrinkResources false
|
|
||||||
|
|
||||||
testCoverageEnabled true
|
|
||||||
applicationIdSuffix ".debug"
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
coreLibraryDesugaringEnabled true
|
|
||||||
|
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
|
||||||
}
|
|
||||||
kotlinOptions {
|
|
||||||
jvmTarget = '1.8'
|
|
||||||
}
|
|
||||||
|
|
||||||
buildFeatures {
|
|
||||||
viewBinding true
|
|
||||||
buildConfig true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
|
||||||
implementation project(':api')
|
|
||||||
implementation project(':db')
|
|
||||||
|
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
|
||||||
|
|
||||||
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'
|
|
||||||
|
|
||||||
implementation 'com.google.android.material:material:1.4.0'
|
|
||||||
implementation 'androidx.cardview:cardview:1.0.0'
|
|
||||||
implementation 'androidx.palette:palette-ktx:1.0.0'
|
|
||||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
|
||||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
|
||||||
implementation 'androidx.preference:preference:1.1.1'
|
|
||||||
implementation "androidx.work:work-runtime-ktx:2.5.0"
|
|
||||||
implementation "androidx.fragment:fragment-ktx:1.3.5"
|
|
||||||
implementation "androidx.browser:browser:1.3.0"
|
|
||||||
|
|
||||||
testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
|
|
||||||
testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
|
|
||||||
|
|
||||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
|
||||||
kapt 'com.github.bumptech.glide:compiler:4.12.0'
|
|
||||||
implementation 'com.github.bumptech.glide:okhttp3-integration:4.12.0'
|
|
||||||
implementation('com.github.bumptech.glide:recyclerview-integration:4.12.0') {
|
|
||||||
transitive = false
|
|
||||||
}
|
|
||||||
|
|
||||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
|
||||||
kapt 'androidx.lifecycle:lifecycle-common-java8:2.3.1'
|
|
||||||
|
|
||||||
implementation 'com.afollestad.material-dialogs:core:0.9.6.0'
|
|
||||||
|
|
||||||
implementation 'com.mikepenz:fastadapter:3.2.9'
|
|
||||||
implementation 'com.mikepenz:fastadapter-commons:3.3.0'
|
|
||||||
implementation 'com.mikepenz:materialdrawer:6.1.2'
|
|
||||||
implementation "com.mikepenz:aboutlibraries:6.2.3"
|
|
||||||
implementation "com.mikepenz:iconics-views:3.2.5"
|
|
||||||
implementation "com.mikepenz:iconics-core:3.2.5"
|
|
||||||
|
|
||||||
debugImplementation 'com.facebook.flipper:flipper:0.96.1'
|
|
||||||
debugImplementation 'com.facebook.soloader:soloader:0.10.1'
|
|
||||||
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.96.1'
|
|
||||||
}
|
|
|
@ -0,0 +1,90 @@
|
||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
kotlin("android")
|
||||||
|
alias(libs.plugins.compose.compiler)
|
||||||
|
id("com.mikepenz.aboutlibraries.plugin")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.readrops.app"
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.readrops.app"
|
||||||
|
|
||||||
|
versionCode = 15
|
||||||
|
versionName = "2.0-beta01"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
|
||||||
|
debug {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
isShrinkResources = false
|
||||||
|
|
||||||
|
applicationIdSuffix = ".debug"
|
||||||
|
enableUnitTestCoverage = true
|
||||||
|
enableAndroidTestCoverage = true
|
||||||
|
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
buildConfig = true
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
lint {
|
||||||
|
abortOnError = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(project(":api"))
|
||||||
|
implementation(project(":db"))
|
||||||
|
|
||||||
|
coreLibraryDesugaring(libs.jdk.desugar)
|
||||||
|
|
||||||
|
implementation(libs.corektx)
|
||||||
|
implementation(libs.appcompat)
|
||||||
|
implementation(libs.material)
|
||||||
|
implementation(libs.palette)
|
||||||
|
implementation(libs.workmanager)
|
||||||
|
implementation(libs.encrypted.preferences)
|
||||||
|
implementation(libs.datastore)
|
||||||
|
implementation(libs.browser)
|
||||||
|
|
||||||
|
implementation(libs.jsoup)
|
||||||
|
|
||||||
|
testImplementation(libs.junit4)
|
||||||
|
androidTestImplementation(libs.bundles.test)
|
||||||
|
|
||||||
|
implementation(platform(libs.compose.bom))
|
||||||
|
implementation(libs.bundles.compose)
|
||||||
|
|
||||||
|
implementation(libs.bundles.voyager)
|
||||||
|
implementation(libs.bundles.lifecycle)
|
||||||
|
implementation(libs.bundles.coil)
|
||||||
|
|
||||||
|
implementation(libs.bundles.coroutines)
|
||||||
|
androidTestImplementation(libs.coroutines.test)
|
||||||
|
|
||||||
|
implementation(libs.bundles.room)
|
||||||
|
implementation(libs.bundles.paging)
|
||||||
|
|
||||||
|
implementation(platform(libs.koin.bom))
|
||||||
|
implementation(libs.bundles.koin)
|
||||||
|
//androidTestImplementation(libs.bundles.kointest)
|
||||||
|
// I don't know why but those dependencies are unreachable when accessed directly from version catalog
|
||||||
|
androidTestImplementation("io.insert-koin:koin-test:${libs.versions.koin.bom.get()}")
|
||||||
|
androidTestImplementation("io.insert-koin:koin-test-junit4:${libs.versions.koin.bom.get()}")
|
||||||
|
|
||||||
|
androidTestImplementation(libs.okhttp.mockserver)
|
||||||
|
|
||||||
|
implementation(libs.aboutlibraries.composem3)
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
# Add project specific ProGuard rules here.
|
# Add project specific ProGuard rules here.
|
||||||
# You can control the set of applied configuration files using the
|
# You can control the set of applied configuration files using the
|
||||||
# proguardFiles setting in build.gradle.
|
# proguardFiles setting in build.gradle.kts.
|
||||||
#
|
#
|
||||||
# For more details, see
|
# For more details, see
|
||||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
@ -31,4 +31,21 @@
|
||||||
|
|
||||||
-keep class com.readrops.api.localfeed.** { *; }
|
-keep class com.readrops.api.localfeed.** { *; }
|
||||||
|
|
||||||
-keep class com.readrops.api.opml.model.** { *; }
|
-keep class com.readrops.api.opml.model.** { *; }
|
||||||
|
|
||||||
|
# Please add these rules to your existing keep rules in order to suppress warnings.
|
||||||
|
# This is generated automatically by the Android Gradle plugin.
|
||||||
|
-dontwarn javax.xml.stream.Location
|
||||||
|
-dontwarn javax.xml.stream.XMLInputFactory
|
||||||
|
-dontwarn javax.xml.stream.XMLStreamReader
|
||||||
|
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||||
|
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||||
|
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||||
|
-dontwarn org.conscrypt.Conscrypt$Version
|
||||||
|
-dontwarn org.conscrypt.Conscrypt
|
||||||
|
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||||
|
-dontwarn org.joda.convert.FromString
|
||||||
|
-dontwarn org.joda.convert.ToString
|
||||||
|
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||||
|
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||||
|
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
|
@ -0,0 +1,58 @@
|
||||||
|
package com.readrops.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.readrops.api.apiModule
|
||||||
|
import com.readrops.api.utils.ApiUtils
|
||||||
|
import com.readrops.app.util.FeedColors
|
||||||
|
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.Test
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koin.test.KoinTestRule
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class FeedColorsTest {
|
||||||
|
|
||||||
|
private val mockServer = MockWebServer()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun before() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
|
||||||
|
KoinTestRule.create {
|
||||||
|
modules(apiModule, module {
|
||||||
|
single { context }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mockServer.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun after() {
|
||||||
|
mockServer.shutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getFeedColorTest() = runBlocking {
|
||||||
|
val stream = TestUtils.loadResource("favicon.ico")
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||||
|
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "image/jpeg")
|
||||||
|
.setBody(Buffer().readFrom(stream))
|
||||||
|
)
|
||||||
|
|
||||||
|
val url = mockServer.url("/rss").toString()
|
||||||
|
val color = FeedColors.getFeedColor(url)
|
||||||
|
|
||||||
|
assertTrue { color != 0 }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.readrops.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.readrops.app.repositories.GetFoldersWithFeeds
|
||||||
|
import com.readrops.db.Database
|
||||||
|
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.entities.account.AccountType
|
||||||
|
import com.readrops.db.filters.MainFilter
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class GetFoldersWithFeedsTest {
|
||||||
|
|
||||||
|
private lateinit var database: Database
|
||||||
|
private lateinit var getFoldersWithFeeds: GetFoldersWithFeeds
|
||||||
|
private val account = Account(accountType = AccountType.LOCAL)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun before() {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
|
||||||
|
|
||||||
|
runTest {
|
||||||
|
account.id = database.accountDao().insert(account).toInt()
|
||||||
|
|
||||||
|
// inserting 3 folders
|
||||||
|
repeat(3) { time ->
|
||||||
|
database.folderDao()
|
||||||
|
.insert(Folder(name = "Folder $time", accountId = account.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// inserting 2 feeds, not linked to any folder
|
||||||
|
repeat(2) { time ->
|
||||||
|
database.feedDao().insert(Feed(name = "Feed $time", accountId = account.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// inserting 2 feeds linked to first folder (Folder 0)
|
||||||
|
repeat(2) { time ->
|
||||||
|
database.feedDao()
|
||||||
|
.insert(Feed(name = "Feed ${time + 2}", folderId = 1, accountId = account.id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// inserting 3 unread items linked to first feed (Feed 0)
|
||||||
|
repeat(3) { time ->
|
||||||
|
database.itemDao()
|
||||||
|
.insert(Item(title = "Item $time", feedId = 1, pubDate = LocalDateTime.now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert 3 read items items linked to second feed (feed 1)
|
||||||
|
repeat(3) { time ->
|
||||||
|
database.itemDao()
|
||||||
|
.insert(
|
||||||
|
Item(
|
||||||
|
title = "Item ${time + 3}",
|
||||||
|
feedId = 3,
|
||||||
|
isRead = true,
|
||||||
|
pubDate = LocalDateTime.now()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun getFoldersWithFeedsTest() = runTest {
|
||||||
|
getFoldersWithFeeds = GetFoldersWithFeeds(database)
|
||||||
|
val foldersAndFeeds =
|
||||||
|
getFoldersWithFeeds.get(account.id, MainFilter.ALL, account.config.useSeparateState)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
assertTrue { foldersAndFeeds.size == 4 }
|
||||||
|
assertTrue { foldersAndFeeds.entries.first().value.size == 2 }
|
||||||
|
assertTrue { foldersAndFeeds.entries.last().key == null }
|
||||||
|
assertTrue { foldersAndFeeds[null]!!.size == 2 }
|
||||||
|
assertTrue { foldersAndFeeds[null]!!.first().unreadCount == 3 }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,116 @@
|
||||||
|
package com.readrops.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.readrops.api.apiModule
|
||||||
|
import com.readrops.api.utils.ApiUtils
|
||||||
|
import com.readrops.api.utils.AuthInterceptor
|
||||||
|
import com.readrops.app.repositories.LocalRSSRepository
|
||||||
|
import com.readrops.db.Database
|
||||||
|
import com.readrops.db.entities.Feed
|
||||||
|
import com.readrops.db.entities.account.Account
|
||||||
|
import com.readrops.db.entities.account.AccountType
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okio.Buffer
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.koin.dsl.module
|
||||||
|
import org.koin.test.KoinTest
|
||||||
|
import org.koin.test.KoinTestRule
|
||||||
|
import org.koin.test.get
|
||||||
|
import java.net.HttpURLConnection
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
|
||||||
|
class LocalRSSRepositoryTest : KoinTest {
|
||||||
|
|
||||||
|
private val mockServer: MockWebServer = MockWebServer()
|
||||||
|
private val account = Account(accountType = AccountType.LOCAL)
|
||||||
|
private lateinit var database: Database
|
||||||
|
private lateinit var repository: LocalRSSRepository
|
||||||
|
private lateinit var feeds: List<Feed>
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun before() = runTest {
|
||||||
|
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
|
||||||
|
|
||||||
|
KoinTestRule.create {
|
||||||
|
modules(apiModule, module {
|
||||||
|
single { database }
|
||||||
|
single {
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.callTimeout(1, TimeUnit.MINUTES)
|
||||||
|
.readTimeout(1, TimeUnit.HOURS)
|
||||||
|
.addInterceptor(get<AuthInterceptor>())
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mockServer.start()
|
||||||
|
val url = mockServer.url("/rss")
|
||||||
|
|
||||||
|
account.id = database.accountDao().insert(account).toInt()
|
||||||
|
feeds = listOf(
|
||||||
|
Feed(
|
||||||
|
name = "feedTest",
|
||||||
|
url = url.toString(),
|
||||||
|
accountId = account.id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
database.feedDao().insert(feeds).apply {
|
||||||
|
feeds.first().id = first().toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
repository = LocalRSSRepository(get(), database, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun synchronizeTest() = runTest {
|
||||||
|
val stream = TestUtils.loadResource("rss_feed.xml")
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||||
|
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8")
|
||||||
|
.setBody(Buffer().readFrom(stream))
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.synchronize(listOf()) {
|
||||||
|
assertEquals(it.name, feeds.first().name)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue { result.first.items.isNotEmpty() }
|
||||||
|
assertTrue {
|
||||||
|
database.itemDao().itemExists(result.first.items.first().remoteId!!, account.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun synchronizeWithFeedsTest(): Unit = runBlocking {
|
||||||
|
val stream = TestUtils.loadResource("rss_feed.xml")
|
||||||
|
|
||||||
|
mockServer.enqueue(
|
||||||
|
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||||
|
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8")
|
||||||
|
.setBody(Buffer().readFrom(stream))
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = repository.synchronize(feeds) {
|
||||||
|
assertEquals(it.name, feeds.first().name)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue { result.first.items.isNotEmpty() }
|
||||||
|
assertTrue {
|
||||||
|
database.itemDao().itemExists(result.first.items.first().remoteId!!, account.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,72 +4,73 @@ import android.content.Context
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.readrops.app.notifications.sync.SyncResultAnalyser
|
import com.readrops.app.repositories.SyncResult
|
||||||
|
import com.readrops.app.sync.SyncAnalyzer
|
||||||
import com.readrops.db.Database
|
import com.readrops.db.Database
|
||||||
import com.readrops.db.entities.Feed
|
import com.readrops.db.entities.Feed
|
||||||
import com.readrops.db.entities.Item
|
import com.readrops.db.entities.Item
|
||||||
import com.readrops.db.entities.account.Account
|
import com.readrops.db.entities.account.Account
|
||||||
import com.readrops.db.entities.account.AccountType
|
import com.readrops.db.entities.account.AccountType
|
||||||
import com.readrops.api.services.SyncResult
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.joda.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Before
|
import org.junit.Before
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class SyncResultAnalyserTest {
|
class SyncAnalyzerTest {
|
||||||
|
|
||||||
private lateinit var database: Database
|
private lateinit var database: Database
|
||||||
|
private lateinit var syncAnalyzer: SyncAnalyzer
|
||||||
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
private val account1 = Account().apply {
|
|
||||||
accountName = "test account 1"
|
|
||||||
accountType = AccountType.FRESHRSS
|
|
||||||
isNotificationsEnabled = true
|
|
||||||
}
|
|
||||||
|
|
||||||
private val account2 = Account().apply {
|
private val nullContentException =
|
||||||
accountName = "test account 2"
|
NullPointerException("Notification content shouldn't be null")
|
||||||
accountType = AccountType.NEXTCLOUD_NEWS
|
|
||||||
|
private val account1 = Account(
|
||||||
|
accountName = "test account 1",
|
||||||
|
accountType = AccountType.FRESHRSS,
|
||||||
|
isNotificationsEnabled = true
|
||||||
|
)
|
||||||
|
|
||||||
|
private val account2 = Account(
|
||||||
|
accountName = "test account 2",
|
||||||
|
accountType = AccountType.NEXTCLOUD_NEWS,
|
||||||
isNotificationsEnabled = false
|
isNotificationsEnabled = false
|
||||||
}
|
)
|
||||||
|
|
||||||
private val account3 = Account().apply {
|
private val account3 = Account(
|
||||||
accountName = "test account 3"
|
accountName = "test account 3",
|
||||||
accountType = AccountType.LOCAL
|
accountType = AccountType.LOCAL,
|
||||||
isNotificationsEnabled = true
|
isNotificationsEnabled = true
|
||||||
}
|
)
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setupDb() {
|
fun setupDb() = runTest {
|
||||||
database = Room.inMemoryDatabaseBuilder(context, Database::class.java)
|
database = Room.inMemoryDatabaseBuilder(context, Database::class.java)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
var account1Id = 0
|
syncAnalyzer = SyncAnalyzer(context, database)
|
||||||
database.accountDao().insert(account1).subscribe { id -> account1Id = id.toInt() }
|
|
||||||
account1.id = account1Id
|
|
||||||
|
|
||||||
var account2Id = 0
|
account1.id = database.accountDao().insert(account1).toInt()
|
||||||
database.accountDao().insert(account2).subscribe { id -> account2Id = id.toInt() }
|
account2.id = database.accountDao().insert(account2).toInt()
|
||||||
account2.id = account2Id
|
account3.id = database.accountDao().insert(account3).toInt()
|
||||||
|
|
||||||
var account3Id = 0
|
val accountIds = listOf(account1.id, account2.id, account3.id)
|
||||||
database.accountDao().insert(account3).subscribe { id -> account3Id = id.toInt() }
|
|
||||||
account3.id = account3Id
|
|
||||||
|
|
||||||
val accountIds = listOf(account1Id, account2Id, account3Id)
|
|
||||||
for (i in 0..2) {
|
for (i in 0..2) {
|
||||||
val feed = Feed().apply {
|
val feed = Feed().apply {
|
||||||
name = "feed ${i + 1}"
|
name = "feed ${i + 1}"
|
||||||
iconUrl = "https://i0.wp.com/mrmondialisation.org/wp-content/uploads/2017/05/ico_final.gif"
|
iconUrl =
|
||||||
|
"https://i0.wp.com/mrmondialisation.org/wp-content/uploads/2017/05/ico_final.gif"
|
||||||
this.accountId = accountIds.find { it == (i + 1) }!!
|
this.accountId = accountIds.find { it == (i + 1) }!!
|
||||||
isNotificationEnabled = i % 2 == 0
|
isNotificationEnabled = i % 2 == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
database.feedDao().insert(feed).subscribe()
|
database.feedDao().insert(feed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,228 +80,194 @@ class SyncResultAnalyserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOneElementEveryWhere() {
|
fun testOneElementEveryWhere() = runTest {
|
||||||
val item = Item().apply {
|
val item = Item(
|
||||||
title = "caseOneElementEveryWhere"
|
title = "caseOneElementEveryWhere",
|
||||||
feedId = 1
|
feedId = 1,
|
||||||
remoteId = "item 1"
|
remoteId = "item 1",
|
||||||
pubDate = LocalDateTime.now()
|
pubDate = LocalDateTime.now()
|
||||||
}
|
)
|
||||||
|
|
||||||
database.itemDao()
|
database.itemDao().insert(item)
|
||||||
.insert(item)
|
|
||||||
.subscribe()
|
|
||||||
|
|
||||||
val syncResult = SyncResult().apply { items = mutableListOf(item) }
|
val syncResult = SyncResult(items = listOf(item))
|
||||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent()
|
|
||||||
|
|
||||||
assertEquals("caseOneElementEveryWhere", notifContent.content)
|
syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
|
||||||
assertEquals("feed 1", notifContent.title)
|
assertEquals("caseOneElementEveryWhere", content.text)
|
||||||
assertTrue(notifContent.largeIcon != null)
|
assertEquals("feed 1", content.title)
|
||||||
assertTrue(notifContent.accountId!! > 0)
|
assertTrue(content.largeIcon != null)
|
||||||
|
assertTrue(content.accountId > 0)
|
||||||
|
} ?: throw nullContentException
|
||||||
|
|
||||||
database.itemDao()
|
database.itemDao().delete(item)
|
||||||
.delete(item)
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTwoItemsOneFeed() {
|
fun testTwoItemsOneFeed() = runTest {
|
||||||
val item = Item().apply {
|
val item = Item(title = "caseTwoItemsOneFeed", feedId = 1)
|
||||||
title = "caseTwoItemsOneFeed"
|
val syncResult = SyncResult(items = listOf(item, item, item))
|
||||||
feedId = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
val syncResult = SyncResult().apply { items = listOf(item, item, item) }
|
syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
|
||||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent()
|
assertEquals(context.getString(R.string.new_items, 3), content.text)
|
||||||
|
assertEquals("feed 1", content.title)
|
||||||
assertEquals(context.getString(R.string.new_items, 3), notifContent.content)
|
assertTrue(content.largeIcon != null)
|
||||||
assertEquals("feed 1", notifContent.title)
|
assertTrue(content.accountId > 0)
|
||||||
assertTrue(notifContent.largeIcon != null)
|
} ?: throw nullContentException
|
||||||
assertTrue(notifContent.accountId!! > 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMultipleFeeds() {
|
fun testMultipleFeeds() = runTest {
|
||||||
val item = Item().apply { feedId = 1 }
|
val item = Item(feedId = 1)
|
||||||
val item2 = Item().apply { feedId = 3 }
|
val item2 = Item(feedId = 3)
|
||||||
|
|
||||||
val syncResult = SyncResult().apply { items = listOf(item, item2) }
|
val syncResult = SyncResult(items = listOf(item, item2))
|
||||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent()
|
|
||||||
|
|
||||||
assertEquals(context.getString(R.string.new_items, 2), notifContent.content)
|
syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
|
||||||
assertEquals(account1.accountName, notifContent.title)
|
assertEquals(context.getString(R.string.new_items, 2), content.text)
|
||||||
assertTrue(notifContent.largeIcon != null)
|
assertEquals(account1.accountName, content.title)
|
||||||
assertTrue(notifContent.accountId!! > 0)
|
assertTrue(content.largeIcon != null)
|
||||||
|
assertTrue(content.accountId > 0)
|
||||||
|
} ?: throw nullContentException
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testMultipleAccounts() {
|
fun testMultipleAccounts() = runTest {
|
||||||
val item = Item().apply { feedId = 1 }
|
val item = Item(feedId = 1)
|
||||||
val item2 = Item().apply { feedId = 3 }
|
val item2 = Item(feedId = 3)
|
||||||
|
|
||||||
val syncResult = SyncResult().apply { items = listOf(item, item2) }
|
val syncResult = SyncResult(items = listOf(item, item2))
|
||||||
val syncResult2 = SyncResult().apply { items = listOf(item, item2) }
|
val syncResult2 = SyncResult(items = listOf(item, item2))
|
||||||
|
val syncResults = mapOf(account1 to syncResult, account3 to syncResult2)
|
||||||
|
|
||||||
val syncResults = mutableMapOf<Account, SyncResult>().apply {
|
syncAnalyzer.getNotificationContent(syncResults)?.let { content ->
|
||||||
put(account1, syncResult)
|
assertEquals(context.getString(R.string.new_items, 4), content.title)
|
||||||
put(account3, syncResult2)
|
} ?: throw nullContentException
|
||||||
}
|
|
||||||
|
|
||||||
val notifContent = SyncResultAnalyser(context, syncResults, database).getSyncNotifContent()
|
|
||||||
|
|
||||||
assertEquals(context.getString(R.string.new_items, 4), notifContent.title)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testAccountNotificationsDisabled() {
|
fun testAccountNotificationsDisabled() = runTest {
|
||||||
val item1 = Item().apply {
|
val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 1)
|
||||||
title = "testAccountNotificationsDisabled"
|
val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 1)
|
||||||
feedId = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
val item2 = Item().apply {
|
val syncResult = SyncResult(items = listOf(item1, item2))
|
||||||
title = "testAccountNotificationsDisabled2"
|
|
||||||
feedId = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
val syncResult = SyncResult().apply { items = listOf(item1, item2) }
|
val content = syncAnalyzer.getNotificationContent(mapOf(account2 to syncResult))
|
||||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account2, syncResult)), database).getSyncNotifContent()
|
assertNull(content)
|
||||||
|
|
||||||
assert(notifContent.title == null)
|
|
||||||
assert(notifContent.content == null)
|
|
||||||
assert(notifContent.largeIcon == null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testFeedNotificationsDisabled() {
|
fun testFeedNotificationsDisabled() = runTest {
|
||||||
val item1 = Item().apply {
|
val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 2)
|
||||||
title = "testAccountNotificationsDisabled"
|
val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 2)
|
||||||
feedId = 2
|
|
||||||
}
|
|
||||||
|
|
||||||
val item2 = Item().apply {
|
val syncResult = SyncResult(items = listOf(item1, item2))
|
||||||
title = "testAccountNotificationsDisabled2"
|
val content = syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))
|
||||||
feedId = 2
|
assertNull(content)
|
||||||
}
|
|
||||||
|
|
||||||
val syncResult = SyncResult().apply { items = listOf(item1, item2) }
|
|
||||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent()
|
|
||||||
|
|
||||||
assert(notifContent.title == null)
|
|
||||||
assert(notifContent.content == null)
|
|
||||||
assert(notifContent.largeIcon == null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTwoAccountsWithOneAccountNotificationsEnabled() {
|
fun testTwoAccountsWithOneAccountNotificationsEnabled() = runTest {
|
||||||
val item1 = Item().apply {
|
val item1 = Item(
|
||||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled"
|
title = "testTwoAccountsWithOneAccountNotificationsEnabled",
|
||||||
feedId = 1
|
feedId = 1,
|
||||||
remoteId = "remoteId 1"
|
remoteId = "remoteId 1",
|
||||||
pubDate = LocalDateTime.now()
|
pubDate = LocalDateTime.now()
|
||||||
}
|
)
|
||||||
|
|
||||||
val item2 = Item().apply {
|
val item2 = Item(
|
||||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled2"
|
title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
|
||||||
feedId = 3
|
feedId = 3
|
||||||
}
|
)
|
||||||
|
|
||||||
val item3 = Item().apply {
|
val item3 = Item(
|
||||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled3"
|
title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
|
||||||
feedId = 3
|
feedId = 3
|
||||||
}
|
)
|
||||||
|
|
||||||
database.itemDao().insert(item1).subscribe()
|
database.itemDao().insert(item1)
|
||||||
|
|
||||||
val syncResult1 = SyncResult().apply { items = listOf(item1) }
|
val syncResult1 = SyncResult(items = listOf(item1))
|
||||||
val syncResult2 = SyncResult().apply { items = listOf(item2, item3) }
|
val syncResult2 = SyncResult(items = listOf(item2, item3))
|
||||||
|
|
||||||
val syncResults = mutableMapOf<Account, SyncResult>().apply {
|
val syncResults = mapOf(account1 to syncResult1, account2 to syncResult2)
|
||||||
put(account1, syncResult1)
|
|
||||||
put(account2, syncResult2)
|
|
||||||
}
|
|
||||||
|
|
||||||
val notifContent = SyncResultAnalyser(context, syncResults, database).getSyncNotifContent()
|
syncAnalyzer.getNotificationContent(syncResults)?.let { content ->
|
||||||
|
assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text)
|
||||||
|
assertEquals("feed 1", content.title)
|
||||||
|
assertTrue(content.largeIcon != null)
|
||||||
|
assertTrue(content.item != null)
|
||||||
|
} ?: throw nullContentException
|
||||||
|
|
||||||
assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", notifContent.content)
|
database.itemDao().delete(item1)
|
||||||
assertEquals("feed 1", notifContent.title)
|
|
||||||
assertTrue(notifContent.largeIcon != null)
|
|
||||||
assertTrue(notifContent.item != null)
|
|
||||||
|
|
||||||
database.itemDao().delete(item1).subscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testTwoAccountsWithOneFeedNotificationEnabled() {
|
fun testTwoAccountsWithOneFeedNotificationEnabled() = runTest {
|
||||||
val item1 = Item().apply {
|
val item1 = Item(
|
||||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled"
|
title = "testTwoAccountsWithOneAccountNotificationsEnabled",
|
||||||
feedId = 1
|
feedId = 1,
|
||||||
remoteId = "remoteId 1"
|
remoteId = "remoteId 1",
|
||||||
pubDate = LocalDateTime.now()
|
pubDate = LocalDateTime.now()
|
||||||
}
|
)
|
||||||
|
|
||||||
val item2 = Item().apply {
|
val item2 = Item(
|
||||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled2"
|
title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
|
||||||
feedId = 2
|
feedId = 2
|
||||||
}
|
)
|
||||||
|
|
||||||
val item3 = Item().apply {
|
val item3 = Item(
|
||||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled3"
|
title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
|
||||||
feedId = 2
|
feedId = 2
|
||||||
}
|
)
|
||||||
|
|
||||||
database.itemDao().insert(item1).subscribe()
|
database.itemDao().insert(item1)
|
||||||
|
|
||||||
val syncResult1 = SyncResult().apply { items = listOf(item1) }
|
val syncResult1 = SyncResult(items = listOf(item1))
|
||||||
val syncResult2 = SyncResult().apply { items = listOf(item2, item3) }
|
val syncResult2 = SyncResult(items = listOf(item2, item3))
|
||||||
|
|
||||||
val syncResults = mutableMapOf<Account, SyncResult>().apply {
|
val syncResults = mapOf(account1 to syncResult1, account2 to syncResult2)
|
||||||
put(account1, syncResult1)
|
|
||||||
put(account2, syncResult2)
|
|
||||||
}
|
|
||||||
|
|
||||||
val notifContent = SyncResultAnalyser(context, syncResults, database).getSyncNotifContent()
|
syncAnalyzer.getNotificationContent(syncResults)?.let { content ->
|
||||||
|
assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text)
|
||||||
|
assertEquals("feed 1", content.title)
|
||||||
|
assertTrue(content.largeIcon != null)
|
||||||
|
assertTrue(content.item != null)
|
||||||
|
} ?: throw nullContentException
|
||||||
|
|
||||||
assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", notifContent.content)
|
database.itemDao().delete(item1)
|
||||||
assertEquals("feed 1", notifContent.title)
|
|
||||||
assertTrue(notifContent.largeIcon != null)
|
|
||||||
assertTrue(notifContent.item != null)
|
|
||||||
|
|
||||||
database.itemDao().delete(item1).subscribe()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testOneAccountTwoFeedsWithOneFeedNotificationEnabled() {
|
fun testOneAccountTwoFeedsWithOneFeedNotificationEnabled() = runTest {
|
||||||
val item1 = Item().apply {
|
val item1 = Item(
|
||||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled"
|
title = "testTwoAccountsWithOneAccountNotificationsEnabled",
|
||||||
feedId = 1
|
feedId = 1,
|
||||||
remoteId = "remoteId 1"
|
remoteId = "remoteId 1",
|
||||||
pubDate = LocalDateTime.now()
|
pubDate = LocalDateTime.now()
|
||||||
}
|
)
|
||||||
|
|
||||||
val item2 = Item().apply {
|
val item2 = Item(
|
||||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled2"
|
title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
|
||||||
feedId = 2
|
feedId = 2
|
||||||
}
|
)
|
||||||
|
|
||||||
val item3 = Item().apply {
|
val item3 = Item(
|
||||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled3"
|
title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
|
||||||
feedId = 2
|
feedId = 2
|
||||||
}
|
)
|
||||||
|
|
||||||
database.itemDao().insert(item1).subscribe()
|
database.itemDao().insert(item1)
|
||||||
|
|
||||||
val syncResult = SyncResult().apply { items = listOf(item1, item2, item3) }
|
val syncResult = SyncResult(items = listOf(item1, item2, item3))
|
||||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent()
|
syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
|
||||||
|
assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", content.text)
|
||||||
|
assertEquals("feed 1", content.title)
|
||||||
|
assertTrue(content.largeIcon != null)
|
||||||
|
assertTrue(content.item != null)
|
||||||
|
assertTrue(content.accountId > 0)
|
||||||
|
} ?: throw nullContentException
|
||||||
|
|
||||||
assertEquals("testTwoAccountsWithOneAccountNotificationsEnabled", notifContent.content)
|
database.itemDao().delete(item1)
|
||||||
assertEquals("feed 1", notifContent.title)
|
|
||||||
assertTrue(notifContent.largeIcon != null)
|
|
||||||
assertTrue(notifContent.item != null)
|
|
||||||
assertTrue(notifContent.accountId!! > 0)
|
|
||||||
|
|
||||||
database.itemDao().delete(item1).subscribe()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.readrops.app
|
||||||
|
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
object TestUtils {
|
||||||
|
|
||||||
|
fun loadResource(path: String): InputStream =
|
||||||
|
javaClass.classLoader?.getResourceAsStream(path)!!
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 7.4 KiB |
|
@ -0,0 +1,61 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"
|
||||||
|
xmlns:atom="http://www.w3.org/1999/xhtml">
|
||||||
|
<channel>
|
||||||
|
<title>Hacker News</title>
|
||||||
|
<atom:link href="https://news.ycombinator.com/feed/" rel="self" />
|
||||||
|
<link>https://news.ycombinator.com/</link>
|
||||||
|
<description>Links for the intellectually curious, ranked by readers.</description>
|
||||||
|
<item>
|
||||||
|
<title>Africa declared free of wild polio</title>
|
||||||
|
<link>https://www.bbc.com/news/world-africa-53887947</link>
|
||||||
|
<pubDate>Tue, 25 Aug 2020 17:15:49 +0000</pubDate>
|
||||||
|
<comments>https://news.ycombinator.com/item?id=24273602</comments>
|
||||||
|
<author>Author 1</author>
|
||||||
|
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273602">Comments</a>]]></description>
|
||||||
|
<media:description>media description</media:description>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Palantir S-1</title>
|
||||||
|
<link>https://www.sec.gov/Archives/edgar/data/1321655/000119312520230013/d904406ds1.htm</link>
|
||||||
|
<pubDate>Tue, 25 Aug 2020 21:03:42 +0000</pubDate>
|
||||||
|
<comments>https://news.ycombinator.com/item?id=24276086</comments>
|
||||||
|
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24276086">Comments</a>]]></description>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Openwifi: Linux mac80211 compatible full-stack 802.11/Wi-Fi design based on SDR</title>
|
||||||
|
<link>https://github.com/open-sdr/openwifi</link>
|
||||||
|
<pubDate>Tue, 25 Aug 2020 17:45:19 +0000</pubDate>
|
||||||
|
<comments>https://news.ycombinator.com/item?id=24273919</comments>
|
||||||
|
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273919">Comments</a>]]></description>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Syllabus for Eric's PhD Students</title>
|
||||||
|
<link>https://docs.google.com/document/d/11D3kHElzS2HQxTwPqcaTnU5HCJ8WGE5brTXI4KLf4dM/edit</link>
|
||||||
|
<pubDate>Tue, 25 Aug 2020 18:55:12 +0000</pubDate>
|
||||||
|
<comments>https://news.ycombinator.com/item?id=24274699</comments>
|
||||||
|
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274699">Comments</a>]]></description>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>WebBundles harmful to content blocking, security tools, and the open web</title>
|
||||||
|
<link>https://brave.com/webbundles-harmful-to-content-blocking-security-tools-and-the-open-web/</link>
|
||||||
|
<pubDate>Tue, 25 Aug 2020 19:18:50 +0000</pubDate>
|
||||||
|
<comments>https://news.ycombinator.com/item?id=24274968</comments>
|
||||||
|
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274968">Comments</a>]]></description>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Zappos CEO Tony Hsieh is stepping down after 21 years</title>
|
||||||
|
<link>https://footwearnews.com/2020/business/executive-moves/zappos-ceo-tony-hsieh-steps-down-1203045974/</link>
|
||||||
|
<pubDate>Tue, 25 Aug 2020 06:11:42 +0000</pubDate>
|
||||||
|
<comments>https://news.ycombinator.com/item?id=24268522</comments>
|
||||||
|
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24268522">Comments</a>]]></description>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Evgeny Kuznetsov practices with Bauer stick that has hole in the blade</title>
|
||||||
|
<link>https://russianmachineneverbreaks.com/2020/07/17/evgeny-kuznetsov-practices-with-bauer-stick-that-has-hole-in-the-blade/</link>
|
||||||
|
<pubDate>Tue, 25 Aug 2020 19:38:09 +0000</pubDate>
|
||||||
|
<comments>https://news.ycombinator.com/item?id=24275159</comments>
|
||||||
|
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24275159">Comments</a>]]></description>
|
||||||
|
</item>
|
||||||
|
</channel>
|
||||||
|
</rss>
|
|
@ -1,19 +0,0 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
package="com.readrops.app">
|
|
||||||
|
|
||||||
<application
|
|
||||||
android:name=".ReadropsDebugApp"
|
|
||||||
tools:ignore="AllowBackup,GoogleAppIndexingWarning"
|
|
||||||
tools:replace="android:name">
|
|
||||||
|
|
||||||
<meta-data android:name="com.niddler.icon" android:value="android"/>
|
|
||||||
|
|
||||||
<provider
|
|
||||||
android:name="androidx.work.impl.WorkManagerInitializer"
|
|
||||||
android:authorities="${applicationId}.workmanager-init"
|
|
||||||
tools:node="remove"
|
|
||||||
android:exported="false" />
|
|
||||||
|
|
||||||
</application>
|
|
||||||
</manifest>
|
|
|
@ -1,55 +0,0 @@
|
||||||
package com.readrops.app;
|
|
||||||
|
|
||||||
import android.util.Log;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.work.Configuration;
|
|
||||||
|
|
||||||
import com.facebook.flipper.android.AndroidFlipperClient;
|
|
||||||
import com.facebook.flipper.android.utils.FlipperUtils;
|
|
||||||
import com.facebook.flipper.core.FlipperClient;
|
|
||||||
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
|
|
||||||
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
|
|
||||||
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.navigation.NavigationFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
|
|
||||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
|
|
||||||
import com.facebook.soloader.SoLoader;
|
|
||||||
|
|
||||||
public class ReadropsDebugApp extends ReadropsApp implements Configuration.Provider {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onCreate() {
|
|
||||||
super.onCreate();
|
|
||||||
SoLoader.init(this, false);
|
|
||||||
|
|
||||||
initFlipper();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initFlipper() {
|
|
||||||
if (FlipperUtils.shouldEnableFlipper(this)) {
|
|
||||||
FlipperClient client = AndroidFlipperClient.getInstance(this);
|
|
||||||
client.addPlugin(new InspectorFlipperPlugin(this, DescriptorMapping.withDefaults()));
|
|
||||||
|
|
||||||
NetworkFlipperPlugin networkPlugin = new NetworkFlipperPlugin();
|
|
||||||
client.addPlugin(networkPlugin);
|
|
||||||
|
|
||||||
client.addPlugin(new DatabasesFlipperPlugin(this));
|
|
||||||
client.addPlugin(CrashReporterPlugin.getInstance());
|
|
||||||
client.addPlugin(NavigationFlipperPlugin.getInstance());
|
|
||||||
client.addPlugin(new SharedPreferencesFlipperPlugin(this));
|
|
||||||
|
|
||||||
client.start();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public Configuration getWorkManagerConfiguration() {
|
|
||||||
return new Configuration.Builder()
|
|
||||||
.setMinimumLoggingLevel(Log.DEBUG)
|
|
||||||
.build();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,26 +1,18 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
|
||||||
package="com.readrops.app">
|
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission
|
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
||||||
android:maxSdkVersion="28" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".ReadropsApp"
|
android:name=".ReadropsApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:requestLegacyExternalStorage="true"
|
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/Theme.Readrops">
|
||||||
android:usesCleartextTraffic="true"
|
|
||||||
tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute">
|
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
@ -32,58 +24,35 @@
|
||||||
android:resource="@xml/file_paths" />
|
android:resource="@xml/file_paths" />
|
||||||
</provider>
|
</provider>
|
||||||
|
|
||||||
<activity
|
<receiver
|
||||||
android:name=".notifications.NotificationPermissionActivity"
|
android:name=".sync.SyncBroadcastReceiver"
|
||||||
android:theme="@style/AppTheme" />
|
android:exported="false" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".item.WebViewActivity"
|
android:name=".MainActivity"
|
||||||
android:theme="@style/AppTheme.NoActionBar" />
|
android:exported="true"
|
||||||
|
|
||||||
<service android:name=".utils.feedscolors.FeedsColorsIntentService" />
|
|
||||||
|
|
||||||
<receiver android:name=".notifications.sync.SyncWorker$MarkReadReceiver" />
|
|
||||||
<receiver android:name=".notifications.sync.SyncWorker$ReadLaterReceiver" />
|
|
||||||
|
|
||||||
<activity android:name=".settings.SettingsActivity" />
|
|
||||||
|
|
||||||
<activity android:name=".account.AccountTypeListActivity" />
|
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".account.AddAccountActivity"
|
|
||||||
android:label="@string/add_account" />
|
|
||||||
<activity
|
|
||||||
android:name=".feedsfolders.ManageFeedsFoldersActivity"
|
|
||||||
android:label="@string/manage_feeds_folders"
|
|
||||||
android:parentActivityName=".itemslist.MainActivity"
|
|
||||||
android:theme="@style/AppTheme.NoActionBar" />
|
|
||||||
<activity
|
|
||||||
android:name=".itemslist.MainActivity"
|
|
||||||
android:label="@string/articles"
|
|
||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:theme="@style/SplashTheme">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
<intent-filter android:label="@string/add_feed">
|
||||||
<activity
|
|
||||||
android:name=".item.ItemActivity"
|
|
||||||
android:parentActivityName=".itemslist.MainActivity"
|
|
||||||
android:theme="@style/AppTheme.NoActionBar" />
|
|
||||||
<activity
|
|
||||||
android:name=".addfeed.AddFeedActivity"
|
|
||||||
android:label="@string/add_feed_title"
|
|
||||||
android:parentActivityName=".itemslist.MainActivity">
|
|
||||||
<intent-filter android:label="@string/new_feed">
|
|
||||||
<action android:name="android.intent.action.SEND" />
|
<action android:name="android.intent.action.SEND" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
|
||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".util.CrashActivity"
|
||||||
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:finishOnTaskLaunch="true"
|
||||||
|
android:launchMode="singleInstance"
|
||||||
|
android:theme="@style/Theme.Readrops" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,81 +1,105 @@
|
||||||
package com.readrops.app
|
package com.readrops.app
|
||||||
|
|
||||||
import androidx.preference.PreferenceManager
|
import android.content.Context
|
||||||
import com.chimerapps.niddler.core.AndroidNiddler
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import com.chimerapps.niddler.core.Niddler
|
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
|
||||||
|
import androidx.datastore.preferences.SharedPreferencesMigration
|
||||||
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
|
import androidx.datastore.preferences.core.emptyPreferences
|
||||||
|
import androidx.datastore.preferences.preferencesDataStoreFile
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
import com.readrops.api.services.Credentials
|
import com.readrops.api.services.Credentials
|
||||||
import com.readrops.app.account.AccountViewModel
|
import com.readrops.app.account.AccountScreenModel
|
||||||
import com.readrops.app.addfeed.AddFeedsViewModel
|
import com.readrops.app.account.credentials.AccountCredentialsScreenMode
|
||||||
import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel
|
import com.readrops.app.account.credentials.AccountCredentialsScreenModel
|
||||||
import com.readrops.app.item.ItemViewModel
|
import com.readrops.app.account.selection.AccountSelectionScreenModel
|
||||||
import com.readrops.app.itemslist.MainViewModel
|
import com.readrops.app.feeds.FeedScreenModel
|
||||||
import com.readrops.app.notifications.NotificationPermissionViewModel
|
import com.readrops.app.item.ItemScreenModel
|
||||||
import com.readrops.app.repositories.FeverRepository
|
import com.readrops.app.more.preferences.PreferencesScreenModel
|
||||||
|
import com.readrops.app.notifications.NotificationsScreenModel
|
||||||
|
import com.readrops.app.repositories.BaseRepository
|
||||||
import com.readrops.app.repositories.FreshRSSRepository
|
import com.readrops.app.repositories.FreshRSSRepository
|
||||||
import com.readrops.app.repositories.LocalFeedRepository
|
import com.readrops.app.repositories.GetFoldersWithFeeds
|
||||||
import com.readrops.app.repositories.NextNewsRepository
|
import com.readrops.app.repositories.LocalRSSRepository
|
||||||
import com.readrops.app.utils.GlideApp
|
import com.readrops.app.repositories.NextcloudNewsRepository
|
||||||
|
import com.readrops.app.timelime.TimelineScreenModel
|
||||||
|
import com.readrops.app.util.DataStorePreferences
|
||||||
|
import com.readrops.app.util.Preferences
|
||||||
import com.readrops.db.entities.account.Account
|
import com.readrops.db.entities.account.Account
|
||||||
import com.readrops.db.entities.account.AccountType
|
import com.readrops.db.entities.account.AccountType
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import org.koin.android.ext.koin.androidApplication
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.androidx.viewmodel.dsl.viewModel
|
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
import org.koin.dsl.module
|
import org.koin.dsl.module
|
||||||
|
|
||||||
val appModule = module {
|
val appModule = module {
|
||||||
|
|
||||||
factory { (account: Account) ->
|
factory { TimelineScreenModel(get(), get(), get()) }
|
||||||
|
|
||||||
|
factory { FeedScreenModel(get(), get(), get(), androidContext()) }
|
||||||
|
|
||||||
|
factory { AccountSelectionScreenModel(get()) }
|
||||||
|
|
||||||
|
factory { AccountScreenModel(get()) }
|
||||||
|
|
||||||
|
factory { (itemId: Int) -> ItemScreenModel(get(), itemId, get()) }
|
||||||
|
|
||||||
|
factory { (accountType: Account, mode: AccountCredentialsScreenMode) ->
|
||||||
|
AccountCredentialsScreenModel(accountType, mode, get())
|
||||||
|
}
|
||||||
|
|
||||||
|
factory { (account: Account) -> NotificationsScreenModel(account, get(), get(), get()) }
|
||||||
|
|
||||||
|
factory { PreferencesScreenModel(get()) }
|
||||||
|
|
||||||
|
single { GetFoldersWithFeeds(get()) }
|
||||||
|
|
||||||
|
factory<BaseRepository> { (account: Account) ->
|
||||||
when (account.accountType) {
|
when (account.accountType) {
|
||||||
AccountType.LOCAL -> LocalFeedRepository(get(), get(), androidContext(), account)
|
AccountType.LOCAL -> LocalRSSRepository(get(), get(), account)
|
||||||
AccountType.NEXTCLOUD_NEWS -> NextNewsRepository(get(parameters = { parametersOf(Credentials.toCredentials(account)) }),
|
AccountType.FRESHRSS -> FreshRSSRepository(
|
||||||
get(), androidContext(), account)
|
get(), account,
|
||||||
AccountType.FRESHRSS -> FreshRSSRepository(get(parameters = { parametersOf(Credentials.toCredentials(account)) }),
|
get(parameters = { parametersOf(Credentials.toCredentials(account)) })
|
||||||
get(), androidContext(), account)
|
)
|
||||||
AccountType.FEVER -> FeverRepository(get(parameters = { parametersOf(Credentials.toCredentials(account)) }),
|
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsRepository(
|
||||||
Dispatchers.IO, get(), get(), account)
|
get(), account,
|
||||||
else -> throw IllegalArgumentException("Account type not supported")
|
get(parameters = { parametersOf(Credentials.toCredentials(account)) })
|
||||||
|
)
|
||||||
|
else -> throw IllegalArgumentException("Unknown account type")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel {
|
single {
|
||||||
MainViewModel(get())
|
val masterKey = MasterKey.Builder(androidContext())
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
EncryptedSharedPreferences.create(
|
||||||
|
androidContext(),
|
||||||
|
"account_credentials",
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel {
|
single {
|
||||||
AddFeedsViewModel(get(), get())
|
PreferenceDataStoreFactory.create(
|
||||||
|
corruptionHandler = ReplaceFileCorruptionHandler(
|
||||||
|
produceNewData = { emptyPreferences() }
|
||||||
|
),
|
||||||
|
migrations = listOf(SharedPreferencesMigration(get(),"settings")),
|
||||||
|
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||||
|
produceFile = { get<Context>().preferencesDataStoreFile("settings") }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewModel {
|
single { DataStorePreferences(get()) }
|
||||||
ItemViewModel(get())
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel {
|
single { Preferences(get()) }
|
||||||
ManageFeedsFoldersViewModel(get())
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel {
|
single { NotificationManagerCompat.from(get()) }
|
||||||
NotificationPermissionViewModel(get())
|
|
||||||
}
|
|
||||||
|
|
||||||
viewModel {
|
|
||||||
AccountViewModel(get())
|
|
||||||
}
|
|
||||||
|
|
||||||
single { GlideApp.with(androidApplication()) }
|
|
||||||
|
|
||||||
single { PreferenceManager.getDefaultSharedPreferences(androidContext()) }
|
|
||||||
|
|
||||||
single<Niddler> {
|
|
||||||
val niddler = AndroidNiddler.Builder()
|
|
||||||
.setNiddlerInformation(AndroidNiddler.fromApplication(get()))
|
|
||||||
.setPort(0)
|
|
||||||
.setMaxStackTraceSize(10)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
niddler.attachToApplication(get())
|
|
||||||
|
|
||||||
niddler.apply { start() }
|
|
||||||
}
|
|
||||||
}
|
}
|
|
@ -0,0 +1,130 @@
|
||||||
|
package com.readrops.app
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.SystemBarStyle
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.NavigationBarDefaults
|
||||||
|
import androidx.compose.material3.surfaceColorAtElevation
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.graphics.toArgb
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import cafe.adriel.voyager.navigator.CurrentScreen
|
||||||
|
import cafe.adriel.voyager.navigator.Navigator
|
||||||
|
import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior
|
||||||
|
import com.readrops.app.account.selection.AccountSelectionScreen
|
||||||
|
import com.readrops.app.account.selection.AccountSelectionScreenModel
|
||||||
|
import com.readrops.app.home.HomeScreen
|
||||||
|
import com.readrops.app.sync.SyncWorker
|
||||||
|
import com.readrops.app.timelime.TimelineTab
|
||||||
|
import com.readrops.app.util.Preferences
|
||||||
|
import com.readrops.app.util.theme.ReadropsTheme
|
||||||
|
import com.readrops.db.Database
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koin.androidx.compose.KoinAndroidContext
|
||||||
|
import org.koin.core.annotation.KoinExperimentalAPI
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.get
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity(), KoinComponent {
|
||||||
|
|
||||||
|
@OptIn(KoinExperimentalAPI::class)
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val screenModel = get<AccountSelectionScreenModel>()
|
||||||
|
val accountExists = screenModel.accountExists()
|
||||||
|
|
||||||
|
val preferences = get<Preferences>()
|
||||||
|
|
||||||
|
val darkFlag = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||||
|
val initialUseDarkTheme = runBlocking {
|
||||||
|
useDarkTheme(preferences.theme.flow.first(), darkFlag)
|
||||||
|
}
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
KoinAndroidContext {
|
||||||
|
val useDarkTheme by preferences.theme.flow
|
||||||
|
.map { mode -> useDarkTheme(mode, darkFlag) }
|
||||||
|
.collectAsState(initial = initialUseDarkTheme)
|
||||||
|
|
||||||
|
ReadropsTheme(
|
||||||
|
useDarkTheme = useDarkTheme
|
||||||
|
) {
|
||||||
|
val navigationBarElevation = NavigationBarDefaults.Elevation
|
||||||
|
|
||||||
|
enableEdgeToEdge(
|
||||||
|
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
|
||||||
|
navigationBarStyle = SystemBarStyle.light(
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(navigationBarElevation)
|
||||||
|
.toArgb(),
|
||||||
|
MaterialTheme.colorScheme.surfaceColorAtElevation(navigationBarElevation)
|
||||||
|
.toArgb()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Navigator(
|
||||||
|
screen = if (accountExists) HomeScreen else AccountSelectionScreen(),
|
||||||
|
disposeBehavior = NavigatorDisposeBehavior(
|
||||||
|
// prevent screenModels being recreated when opening a screen from a tab
|
||||||
|
disposeNestedNavigators = false
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
CurrentScreen()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
|
||||||
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
|
handleIntent(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleIntent(intent: Intent) {
|
||||||
|
when {
|
||||||
|
intent.hasExtra(SyncWorker.ACCOUNT_ID_KEY) -> {
|
||||||
|
val accountId = intent.getIntExtra(SyncWorker.ACCOUNT_ID_KEY, -1)
|
||||||
|
get<Database>().accountDao()
|
||||||
|
.updateCurrentAccount(accountId)
|
||||||
|
|
||||||
|
HomeScreen.openTab(TimelineTab)
|
||||||
|
|
||||||
|
if (intent.hasExtra(SyncWorker.ITEM_ID_KEY)) {
|
||||||
|
val itemId = intent.getIntExtra(SyncWorker.ITEM_ID_KEY, -1)
|
||||||
|
HomeScreen.openItemScreen(itemId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
intent.action != null && intent.action == Intent.ACTION_SEND -> {
|
||||||
|
HomeScreen.openAddFeedDialog(intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun useDarkTheme(mode: String, darkFlag: Int): Boolean {
|
||||||
|
return when (mode) {
|
||||||
|
"light" -> false
|
||||||
|
"dark" -> true
|
||||||
|
else -> darkFlag == Configuration.UI_MODE_NIGHT_YES
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,26 +3,38 @@ package com.readrops.app
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
import androidx.core.app.NotificationManagerCompat
|
||||||
import androidx.preference.PreferenceManager
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.disk.DiskCache
|
||||||
import com.readrops.api.apiModule
|
import com.readrops.api.apiModule
|
||||||
import com.readrops.app.utils.SharedPreferencesManager
|
import com.readrops.app.util.CrashActivity
|
||||||
import com.readrops.db.dbModule
|
import com.readrops.db.dbModule
|
||||||
import io.reactivex.plugins.RxJavaPlugins
|
|
||||||
import org.koin.android.ext.koin.androidContext
|
import org.koin.android.ext.koin.androidContext
|
||||||
import org.koin.android.ext.koin.androidLogger
|
import org.koin.android.ext.koin.androidLogger
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.get
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.core.logger.Level
|
import org.koin.core.logger.Level
|
||||||
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
open class ReadropsApp : Application() {
|
open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory {
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
RxJavaPlugins.setErrorHandler { e: Throwable? -> }
|
|
||||||
|
|
||||||
createNotificationChannels()
|
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
||||||
PreferenceManager.setDefaultValues(this, R.xml.preferences, false)
|
val intent = Intent(this, CrashActivity::class.java).apply {
|
||||||
|
putExtra(CrashActivity.THROWABLE_KEY, throwable)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
|
}
|
||||||
|
|
||||||
|
startActivity(intent)
|
||||||
|
exitProcess(0)
|
||||||
|
}
|
||||||
|
|
||||||
startKoin {
|
startKoin {
|
||||||
androidLogger(Level.ERROR)
|
androidLogger(Level.ERROR)
|
||||||
|
@ -31,41 +43,37 @@ open class ReadropsApp : Application() {
|
||||||
modules(apiModule, dbModule, appModule)
|
modules(apiModule, dbModule, appModule)
|
||||||
}
|
}
|
||||||
|
|
||||||
val theme = when (SharedPreferencesManager.readString(SharedPreferencesManager.SharedPrefKey.DARK_THEME)) {
|
createNotificationChannels()
|
||||||
getString(R.string.theme_value_light) -> AppCompatDelegate.MODE_NIGHT_NO
|
}
|
||||||
getString(R.string.theme_value_dark) -> AppCompatDelegate.MODE_NIGHT_YES
|
|
||||||
else -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
|
||||||
}
|
|
||||||
|
|
||||||
AppCompatDelegate.setDefaultNightMode(theme)
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
return ImageLoader.Builder(this)
|
||||||
|
.okHttpClient { get() }
|
||||||
|
.diskCache {
|
||||||
|
DiskCache.Builder()
|
||||||
|
.directory(this.cacheDir.resolve("image_cache"))
|
||||||
|
.maxSizePercent(0.05)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
.crossfade(true)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createNotificationChannels() {
|
private fun createNotificationChannels() {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
val feedsColorsChannel = NotificationChannel(FEEDS_COLORS_CHANNEL_ID,
|
val syncChannel = NotificationChannel(
|
||||||
getString(R.string.feeds_colors), NotificationManager.IMPORTANCE_DEFAULT)
|
SYNC_CHANNEL_ID,
|
||||||
feedsColorsChannel.description = getString(R.string.get_feeds_colors)
|
getString(R.string.auto_synchro),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
val opmlExportChannel = NotificationChannel(OPML_EXPORT_CHANNEL_ID,
|
)
|
||||||
getString(R.string.opml_export), NotificationManager.IMPORTANCE_DEFAULT)
|
|
||||||
opmlExportChannel.description = getString(R.string.opml_export_description)
|
|
||||||
|
|
||||||
val syncChannel = NotificationChannel(SYNC_CHANNEL_ID,
|
|
||||||
getString(R.string.auto_synchro), NotificationManager.IMPORTANCE_LOW)
|
|
||||||
syncChannel.description = getString(R.string.account_synchro)
|
syncChannel.description = getString(R.string.account_synchro)
|
||||||
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)!!
|
NotificationManagerCompat.from(this)
|
||||||
|
.createNotificationChannel(syncChannel)
|
||||||
manager.createNotificationChannel(feedsColorsChannel)
|
|
||||||
manager.createNotificationChannel(opmlExportChannel)
|
|
||||||
manager.createNotificationChannel(syncChannel)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val FEEDS_COLORS_CHANNEL_ID = "feedsColorsChannel"
|
|
||||||
const val OPML_EXPORT_CHANNEL_ID = "opmlExportChannel"
|
|
||||||
const val SYNC_CHANNEL_ID = "syncChannel"
|
const val SYNC_CHANNEL_ID = "syncChannel"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -0,0 +1,237 @@
|
||||||
|
package com.readrops.app.account
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.compose.runtime.Stable
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
|
import com.readrops.api.opml.OPMLParser
|
||||||
|
import com.readrops.app.base.TabScreenModel
|
||||||
|
import com.readrops.app.repositories.ErrorResult
|
||||||
|
import com.readrops.app.repositories.GetFoldersWithFeeds
|
||||||
|
import com.readrops.app.util.components.TextFieldError
|
||||||
|
import com.readrops.app.util.components.dialog.TextFieldDialogState
|
||||||
|
import com.readrops.db.Database
|
||||||
|
import com.readrops.db.entities.Feed
|
||||||
|
import com.readrops.db.entities.Folder
|
||||||
|
import com.readrops.db.entities.account.Account
|
||||||
|
import com.readrops.db.entities.account.AccountType
|
||||||
|
import com.readrops.db.filters.MainFilter
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.component.get
|
||||||
|
|
||||||
|
class AccountScreenModel(
|
||||||
|
private val database: Database,
|
||||||
|
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
) : TabScreenModel(database) {
|
||||||
|
|
||||||
|
private val _closeHome = MutableStateFlow(false)
|
||||||
|
val closeHome = _closeHome.asStateFlow()
|
||||||
|
|
||||||
|
private val _accountState = MutableStateFlow(AccountState())
|
||||||
|
val accountState = _accountState.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
accountEvent.collect { account ->
|
||||||
|
_accountState.update {
|
||||||
|
it.copy(
|
||||||
|
account = account
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
database.accountDao().selectAllAccounts()
|
||||||
|
.map { it.filter { account -> !account.isCurrentAccount } }
|
||||||
|
.collect { accounts ->
|
||||||
|
_accountState.update { it.copy(accounts = accounts) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun openDialog(dialog: DialogState) {
|
||||||
|
if (dialog is DialogState.RenameAccount) {
|
||||||
|
_accountState.update { it.copy(renameAccountState = TextFieldDialogState(value = dialog.name)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
_accountState.update { it.copy(dialog = dialog) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun closeDialog(dialog: DialogState? = null) {
|
||||||
|
if (dialog is DialogState.ErrorList) {
|
||||||
|
_accountState.update { it.copy(synchronizationErrors = null) }
|
||||||
|
} else if (dialog is DialogState.Error) {
|
||||||
|
_accountState.update { it.copy(error = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
_accountState.update { it.copy(dialog = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteAccount() {
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
database.accountDao()
|
||||||
|
.delete(currentAccount!!)
|
||||||
|
|
||||||
|
if (_accountState.value.accounts.isNotEmpty()) {
|
||||||
|
database.accountDao().updateCurrentAccount(_accountState.value.accounts.first().id)
|
||||||
|
} else {
|
||||||
|
_closeHome.update { true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun exportOPMLFile(uri: Uri, context: Context) {
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
val stream = context.contentResolver.openOutputStream(uri)
|
||||||
|
if (stream == null) {
|
||||||
|
_accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
val foldersAndFeeds =
|
||||||
|
GetFoldersWithFeeds(database).get(
|
||||||
|
currentAccount!!.id,
|
||||||
|
MainFilter.ALL,
|
||||||
|
currentAccount!!.config.useSeparateState
|
||||||
|
).first()
|
||||||
|
|
||||||
|
OPMLParser.write(foldersAndFeeds, stream)
|
||||||
|
|
||||||
|
_accountState.update {
|
||||||
|
it.copy(
|
||||||
|
opmlExportSuccess = true,
|
||||||
|
opmlExportUri = uri
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseOPMLFile(uri: Uri, context: Context) {
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
val foldersAndFeeds: Map<Folder?, List<Feed>>
|
||||||
|
|
||||||
|
try {
|
||||||
|
val stream = context.contentResolver.openInputStream(uri)
|
||||||
|
if (stream == null) {
|
||||||
|
_accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
foldersAndFeeds = OPMLParser.read(stream)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_accountState.update { it.copy(error = e) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
openDialog(
|
||||||
|
DialogState.OPMLImport(
|
||||||
|
currentFeed = foldersAndFeeds.values.first().first().name!!,
|
||||||
|
feedCount = 0,
|
||||||
|
feedMax = foldersAndFeeds.values.flatten().size
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val errors = repository?.insertOPMLFoldersAndFeeds(
|
||||||
|
foldersAndFeeds = foldersAndFeeds,
|
||||||
|
onUpdate = { feed ->
|
||||||
|
_accountState.update {
|
||||||
|
val dialog = (it.dialog as DialogState.OPMLImport)
|
||||||
|
|
||||||
|
it.copy(
|
||||||
|
dialog = dialog.copy(
|
||||||
|
currentFeed = feed.name!!,
|
||||||
|
feedCount = dialog.feedCount + 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
closeDialog()
|
||||||
|
|
||||||
|
_accountState.update {
|
||||||
|
it.copy(synchronizationErrors = if (errors!!.isNotEmpty()) errors else null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetOPMLState() =
|
||||||
|
_accountState.update { it.copy(opmlExportUri = null, opmlExportSuccess = false) }
|
||||||
|
|
||||||
|
fun resetCloseHome() = _closeHome.update { false }
|
||||||
|
|
||||||
|
fun updateCurrentAccount(account: Account) {
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
database.accountDao().updateCurrentAccount(account.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createLocalAccount() {
|
||||||
|
val context = get<Context>()
|
||||||
|
val account = Account(
|
||||||
|
accountName = context.getString(AccountType.LOCAL.typeName),
|
||||||
|
accountType = AccountType.LOCAL,
|
||||||
|
isCurrentAccount = true
|
||||||
|
)
|
||||||
|
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
database.accountDao().insert(account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setAccountRenameStateName(name: String) = _accountState.update {
|
||||||
|
it.copy(
|
||||||
|
renameAccountState = it.renameAccountState.copy(
|
||||||
|
value = name,
|
||||||
|
textFieldError = null
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun renameAccount() = with(_accountState) {
|
||||||
|
if (value.renameAccountState.value.isEmpty()) {
|
||||||
|
update { it.copy(renameAccountState = it.renameAccountState.copy(textFieldError = TextFieldError.EmptyField)) }
|
||||||
|
return@with
|
||||||
|
}
|
||||||
|
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
database.accountDao().renameAccount(value.account.id, value.renameAccountState.value)
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Stable
|
||||||
|
data class AccountState(
|
||||||
|
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
|
||||||
|
val dialog: DialogState? = null,
|
||||||
|
val synchronizationErrors: ErrorResult? = null,
|
||||||
|
val error: Exception? = null,
|
||||||
|
val opmlExportSuccess: Boolean = false,
|
||||||
|
val opmlExportUri: Uri? = null,
|
||||||
|
val accounts: List<Account> = emptyList(),
|
||||||
|
val renameAccountState: TextFieldDialogState = TextFieldDialogState()
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface DialogState {
|
||||||
|
data object DeleteAccount : DialogState
|
||||||
|
data object NewAccount : DialogState
|
||||||
|
data class OPMLImport(val currentFeed: String, val feedCount: Int, val feedMax: Int) :
|
||||||
|
DialogState
|
||||||
|
|
||||||
|
data class ErrorList(val errorResult: ErrorResult) : DialogState
|
||||||
|
data class Error(val exception: Exception) : DialogState
|
||||||
|
|
||||||
|
data object OPMLChoice : DialogState
|
||||||
|
|
||||||
|
data class RenameAccount(val name: String) : DialogState
|
||||||
|
}
|
|
@ -0,0 +1,428 @@
|
||||||
|
package com.readrops.app.account
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.AccountCircle
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.SnackbarResult
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import cafe.adriel.voyager.koin.getScreenModel
|
||||||
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import cafe.adriel.voyager.navigator.tab.Tab
|
||||||
|
import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||||
|
import com.readrops.api.utils.ApiUtils
|
||||||
|
import com.readrops.app.R
|
||||||
|
import com.readrops.app.account.credentials.AccountCredentialsScreen
|
||||||
|
import com.readrops.app.account.credentials.AccountCredentialsScreenMode
|
||||||
|
import com.readrops.app.account.selection.AccountSelectionDialog
|
||||||
|
import com.readrops.app.account.selection.AccountSelectionScreen
|
||||||
|
import com.readrops.app.account.selection.adaptiveIconPainterResource
|
||||||
|
import com.readrops.app.notifications.NotificationsScreen
|
||||||
|
import com.readrops.app.repositories.ErrorResult
|
||||||
|
import com.readrops.app.timelime.ErrorListDialog
|
||||||
|
import com.readrops.app.util.components.SelectableIconText
|
||||||
|
import com.readrops.app.util.components.SelectableImageText
|
||||||
|
import com.readrops.app.util.components.ThreeDotsMenu
|
||||||
|
import com.readrops.app.util.components.dialog.ErrorDialog
|
||||||
|
import com.readrops.app.util.components.dialog.TextFieldDialog
|
||||||
|
import com.readrops.app.util.components.dialog.TwoChoicesDialog
|
||||||
|
import com.readrops.app.util.theme.LargeSpacer
|
||||||
|
import com.readrops.app.util.theme.MediumSpacer
|
||||||
|
import com.readrops.app.util.theme.VeryShortSpacer
|
||||||
|
import com.readrops.app.util.theme.spacing
|
||||||
|
import com.readrops.db.entities.account.Account
|
||||||
|
import com.readrops.db.entities.account.AccountType
|
||||||
|
|
||||||
|
object AccountTab : Tab {
|
||||||
|
|
||||||
|
override val options: TabOptions
|
||||||
|
@Composable
|
||||||
|
get() = TabOptions(
|
||||||
|
index = 3u,
|
||||||
|
title = stringResource(R.string.account)
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val context = LocalContext.current
|
||||||
|
val screenModel = getScreenModel<AccountScreenModel>()
|
||||||
|
|
||||||
|
val closeHome by screenModel.closeHome.collectAsStateWithLifecycle()
|
||||||
|
val state by screenModel.accountState.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
if (closeHome) {
|
||||||
|
navigator.replaceAll(AccountSelectionScreen())
|
||||||
|
screenModel.resetCloseHome()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.error) {
|
||||||
|
if (state.error != null) {
|
||||||
|
val action = snackbarHostState.showSnackbar(
|
||||||
|
message = context.resources.getQuantityString(
|
||||||
|
R.plurals.error_occurred,
|
||||||
|
1
|
||||||
|
),
|
||||||
|
actionLabel = context.getString(R.string.details),
|
||||||
|
duration = SnackbarDuration.Short
|
||||||
|
)
|
||||||
|
|
||||||
|
if (action == SnackbarResult.ActionPerformed) {
|
||||||
|
screenModel.openDialog(DialogState.Error(state.error!!))
|
||||||
|
} else {
|
||||||
|
screenModel.closeDialog(DialogState.Error(state.error!!))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.synchronizationErrors) {
|
||||||
|
if (state.synchronizationErrors != null) {
|
||||||
|
val action = snackbarHostState.showSnackbar(
|
||||||
|
message = context.resources.getQuantityString(
|
||||||
|
R.plurals.error_occurred,
|
||||||
|
state.synchronizationErrors!!.size
|
||||||
|
),
|
||||||
|
actionLabel = context.getString(R.string.details),
|
||||||
|
duration = SnackbarDuration.Short
|
||||||
|
)
|
||||||
|
|
||||||
|
if (action == SnackbarResult.ActionPerformed) {
|
||||||
|
screenModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
|
||||||
|
} else {
|
||||||
|
screenModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.opmlExportSuccess) {
|
||||||
|
if (state.opmlExportSuccess) {
|
||||||
|
val action = snackbarHostState.showSnackbar(
|
||||||
|
message = context.getString(R.string.opml_export_success),
|
||||||
|
actionLabel = context.resources.getString(R.string.open)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (action == SnackbarResult.ActionPerformed) {
|
||||||
|
Intent().apply {
|
||||||
|
this.action = Intent.ACTION_VIEW
|
||||||
|
setDataAndType(state.opmlExportUri, "text/xml")
|
||||||
|
}.also {
|
||||||
|
context.startActivity(Intent.createChooser(it, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
screenModel.resetOPMLState()
|
||||||
|
} else {
|
||||||
|
screenModel.resetOPMLState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AccountDialogs(
|
||||||
|
state = state,
|
||||||
|
screenModel = screenModel
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(text = stringResource(R.string.account)) }
|
||||||
|
)
|
||||||
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
FloatingActionButton(
|
||||||
|
onClick = { screenModel.openDialog(DialogState.NewAccount) }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(id = R.drawable.ic_add_account),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = MaterialTheme.spacing.mediumSpacing)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = adaptiveIconPainterResource(id = state.account.accountType!!.iconRes),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
MediumSpacer()
|
||||||
|
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = state.account.accountName!!,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.account.displayedName != null) {
|
||||||
|
VeryShortSpacer()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = state.account.displayedName!!,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.account.isLocal) {
|
||||||
|
ThreeDotsMenu(
|
||||||
|
items = mapOf(1 to stringResource(id = R.string.rename_account)),
|
||||||
|
onItemClick = {
|
||||||
|
screenModel.openDialog(DialogState.RenameAccount(state.account.accountName!!))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LargeSpacer()
|
||||||
|
|
||||||
|
if (!state.account.isLocal) {
|
||||||
|
SelectableIconText(
|
||||||
|
icon = painterResource(id = R.drawable.ic_person),
|
||||||
|
text = stringResource(R.string.credentials),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
|
||||||
|
spacing = MaterialTheme.spacing.largeSpacing,
|
||||||
|
padding = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
iconSize = 24.dp,
|
||||||
|
onClick = {
|
||||||
|
navigator.push(
|
||||||
|
AccountCredentialsScreen(
|
||||||
|
state.account,
|
||||||
|
AccountCredentialsScreenMode.EDIT_CREDENTIALS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectableIconText(
|
||||||
|
icon = painterResource(id = R.drawable.ic_notifications),
|
||||||
|
text = stringResource(R.string.notifications),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
|
||||||
|
spacing = MaterialTheme.spacing.largeSpacing,
|
||||||
|
padding = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
iconSize = 24.dp,
|
||||||
|
onClick = { navigator.push(NotificationsScreen(state.account)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.account.isLocal) {
|
||||||
|
SelectableIconText(
|
||||||
|
icon = painterResource(id = R.drawable.ic_import_export),
|
||||||
|
text = stringResource(R.string.opml_import_export),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
|
||||||
|
spacing = MaterialTheme.spacing.largeSpacing,
|
||||||
|
padding = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
iconSize = 24.dp,
|
||||||
|
onClick = { screenModel.openDialog(DialogState.OPMLChoice) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SelectableIconText(
|
||||||
|
icon = rememberVectorPainter(image = Icons.Default.AccountCircle),
|
||||||
|
text = stringResource(R.string.delete_account),
|
||||||
|
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
|
||||||
|
spacing = MaterialTheme.spacing.largeSpacing,
|
||||||
|
padding = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
iconSize = 24.dp,
|
||||||
|
onClick = { screenModel.openDialog(DialogState.DeleteAccount) }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (state.accounts.isNotEmpty()) {
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.other_accounts),
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
|
||||||
|
)
|
||||||
|
|
||||||
|
VeryShortSpacer()
|
||||||
|
|
||||||
|
for (account in state.accounts) {
|
||||||
|
SelectableImageText(
|
||||||
|
image = adaptiveIconPainterResource(id = account.accountType!!.iconRes),
|
||||||
|
text = account.accountName!!,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
padding = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
imageSize = 24.dp,
|
||||||
|
onClick = { screenModel.updateCurrentAccount(account) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun AccountDialogs(state: AccountState, screenModel: AccountScreenModel) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
|
||||||
|
val opmlImportLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
uri?.let { screenModel.parseOPMLFile(uri, context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val opmlExportLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/xml")) { uri ->
|
||||||
|
uri?.let { screenModel.exportOPMLFile(uri, context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val dialog = state.dialog) {
|
||||||
|
is DialogState.DeleteAccount -> {
|
||||||
|
TwoChoicesDialog(
|
||||||
|
title = stringResource(R.string.delete_account),
|
||||||
|
text = stringResource(R.string.delete_account_question),
|
||||||
|
icon = rememberVectorPainter(image = Icons.Default.Delete),
|
||||||
|
confirmText = stringResource(R.string.delete),
|
||||||
|
dismissText = stringResource(R.string.cancel),
|
||||||
|
onDismiss = { screenModel.closeDialog() },
|
||||||
|
onConfirm = {
|
||||||
|
screenModel.closeDialog()
|
||||||
|
screenModel.deleteAccount()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DialogState.NewAccount -> {
|
||||||
|
AccountSelectionDialog(
|
||||||
|
onDismiss = { screenModel.closeDialog() },
|
||||||
|
onValidate = { accountType ->
|
||||||
|
screenModel.closeDialog()
|
||||||
|
|
||||||
|
if (accountType == AccountType.LOCAL) {
|
||||||
|
screenModel.createLocalAccount()
|
||||||
|
} else {
|
||||||
|
val account = Account(
|
||||||
|
accountType = accountType,
|
||||||
|
accountName = context.resources.getString(accountType.typeName)
|
||||||
|
)
|
||||||
|
navigator.push(
|
||||||
|
AccountCredentialsScreen(
|
||||||
|
account,
|
||||||
|
AccountCredentialsScreenMode.NEW_CREDENTIALS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DialogState.OPMLImport -> {
|
||||||
|
OPMLImportProgressDialog(
|
||||||
|
currentFeed = dialog.currentFeed,
|
||||||
|
feedCount = dialog.feedCount,
|
||||||
|
feedMax = dialog.feedMax
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DialogState.ErrorList -> {
|
||||||
|
ErrorListDialog(
|
||||||
|
errorResult = dialog.errorResult as ErrorResult, // cast needed by assembleRelease
|
||||||
|
onDismiss = { screenModel.closeDialog(dialog) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DialogState.Error -> {
|
||||||
|
ErrorDialog(
|
||||||
|
exception = dialog.exception,
|
||||||
|
onDismiss = { screenModel.closeDialog(dialog) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DialogState.OPMLChoice -> {
|
||||||
|
OPMLChoiceDialog(
|
||||||
|
onChoice = {
|
||||||
|
if (it == OPML.IMPORT) {
|
||||||
|
opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray())
|
||||||
|
} else {
|
||||||
|
opmlExportLauncher.launch("subscriptions.opml")
|
||||||
|
}
|
||||||
|
|
||||||
|
screenModel.closeDialog()
|
||||||
|
},
|
||||||
|
onDismiss = { screenModel.closeDialog() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
is DialogState.RenameAccount -> {
|
||||||
|
TextFieldDialog(
|
||||||
|
title = stringResource(id = R.string.rename_account),
|
||||||
|
icon = painterResource(id = R.drawable.ic_person),
|
||||||
|
label = stringResource(id = R.string.name),
|
||||||
|
state = state.renameAccountState,
|
||||||
|
onValueChange = { screenModel.setAccountRenameStateName(it) },
|
||||||
|
onValidate = { screenModel.renameAccount() },
|
||||||
|
onDismiss = { screenModel.closeDialog() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,197 +0,0 @@
|
||||||
package com.readrops.app.account;
|
|
||||||
|
|
||||||
import static com.readrops.app.utils.OPMLHelper.OPEN_OPML_FILE_REQUEST;
|
|
||||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
|
||||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_TYPE;
|
|
||||||
import static com.readrops.app.utils.ReadropsKeys.FROM_MAIN_ACTIVITY;
|
|
||||||
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.os.Parcelable;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.LinearLayout;
|
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
import androidx.recyclerview.widget.DividerItemDecoration;
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
|
||||||
|
|
||||||
import com.afollestad.materialdialogs.MaterialDialog;
|
|
||||||
import com.readrops.app.R;
|
|
||||||
import com.readrops.app.databinding.ActivityAccountTypeListBinding;
|
|
||||||
import com.readrops.app.itemslist.MainActivity;
|
|
||||||
import com.readrops.app.utils.OPMLHelper;
|
|
||||||
import com.readrops.app.utils.Utils;
|
|
||||||
import com.readrops.db.entities.account.Account;
|
|
||||||
import com.readrops.db.entities.account.AccountType;
|
|
||||||
|
|
||||||
import org.koin.android.compat.ViewModelCompat;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.observers.DisposableCompletableObserver;
|
|
||||||
import io.reactivex.observers.DisposableSingleObserver;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
|
|
||||||
public class AccountTypeListActivity extends AppCompatActivity {
|
|
||||||
|
|
||||||
private static final String TAG = AccountTypeListActivity.class.getSimpleName();
|
|
||||||
|
|
||||||
private ActivityAccountTypeListBinding binding;
|
|
||||||
private AccountTypeListAdapter adapter;
|
|
||||||
private AccountViewModel viewModel;
|
|
||||||
|
|
||||||
private boolean fromMainActivity;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
|
|
||||||
binding = ActivityAccountTypeListBinding.inflate(getLayoutInflater());
|
|
||||||
setContentView(binding.getRoot());
|
|
||||||
|
|
||||||
viewModel = ViewModelCompat.getViewModel(this, AccountViewModel.class);
|
|
||||||
|
|
||||||
setTitle(R.string.new_account);
|
|
||||||
|
|
||||||
binding.accountTypeRecyclerview.setLayoutManager(new LinearLayoutManager(this));
|
|
||||||
binding.accountTypeRecyclerview.addItemDecoration(new DividerItemDecoration(this, LinearLayout.VERTICAL));
|
|
||||||
|
|
||||||
fromMainActivity = getIntent().getBooleanExtra(FROM_MAIN_ACTIVITY, false);
|
|
||||||
|
|
||||||
if (fromMainActivity)
|
|
||||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
|
||||||
|
|
||||||
adapter = new AccountTypeListAdapter(accountType -> {
|
|
||||||
if (accountType != AccountType.LOCAL) {
|
|
||||||
Intent intent = new Intent(getApplicationContext(), AddAccountActivity.class);
|
|
||||||
|
|
||||||
if (fromMainActivity)
|
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT);
|
|
||||||
|
|
||||||
intent.putExtra(ACCOUNT_TYPE, (Parcelable) accountType);
|
|
||||||
|
|
||||||
startActivity(intent);
|
|
||||||
finish();
|
|
||||||
} else {
|
|
||||||
Account account = new Account(null, getString(AccountType.LOCAL.getTypeName()), AccountType.LOCAL);
|
|
||||||
account.setCurrentAccount(true);
|
|
||||||
|
|
||||||
viewModel.insert(account)
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(new DisposableSingleObserver<Long>() {
|
|
||||||
@Override
|
|
||||||
public void onSuccess(Long id) {
|
|
||||||
account.setId(id.intValue());
|
|
||||||
goToNextActivity(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable e) {
|
|
||||||
Log.e(TAG, e.getMessage());
|
|
||||||
Utils.showSnackbar(binding.accountTypeListRoot, e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
|
|
||||||
binding.accountTypeRecyclerview.setAdapter(adapter);
|
|
||||||
adapter.setAccountTypes(getData());
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<AccountType> getData() {
|
|
||||||
List<AccountType> accountTypes = new ArrayList<>();
|
|
||||||
|
|
||||||
accountTypes.add(AccountType.LOCAL);
|
|
||||||
accountTypes.add(AccountType.NEXTCLOUD_NEWS);
|
|
||||||
accountTypes.add(AccountType.FRESHRSS);
|
|
||||||
accountTypes.add(AccountType.FEVER);
|
|
||||||
|
|
||||||
return accountTypes;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item) {
|
|
||||||
switch (item.getItemId()) {
|
|
||||||
case android.R.id.home:
|
|
||||||
finish();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onOptionsItemSelected(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void openOPMLFile(View view) {
|
|
||||||
OPMLHelper.openFileIntent(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
|
|
||||||
if (requestCode == OPEN_OPML_FILE_REQUEST && resultCode == RESULT_OK && data != null) {
|
|
||||||
Uri uri = data.getData();
|
|
||||||
|
|
||||||
MaterialDialog dialog = new MaterialDialog.Builder(this)
|
|
||||||
.title(R.string.opml_processing)
|
|
||||||
.content(R.string.operation_takes_time)
|
|
||||||
.progress(true, 100)
|
|
||||||
.cancelable(false)
|
|
||||||
.show();
|
|
||||||
|
|
||||||
parseOPMLFile(uri, dialog);
|
|
||||||
}
|
|
||||||
|
|
||||||
super.onActivityResult(requestCode, resultCode, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void parseOPMLFile(Uri uri, MaterialDialog dialog) {
|
|
||||||
Account account = new Account(null, getString(AccountType.LOCAL.getTypeName()), AccountType.LOCAL);
|
|
||||||
account.setCurrentAccount(true);
|
|
||||||
|
|
||||||
viewModel.insert(account)
|
|
||||||
.flatMapCompletable(id -> {
|
|
||||||
account.setId(id.intValue());
|
|
||||||
viewModel.setAccount(account);
|
|
||||||
|
|
||||||
return viewModel.parseOPMLFile(uri, this);
|
|
||||||
})
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(new DisposableCompletableObserver() {
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
dialog.dismiss();
|
|
||||||
goToNextActivity(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable e) {
|
|
||||||
Log.e(TAG, e.getMessage());
|
|
||||||
|
|
||||||
dialog.dismiss();
|
|
||||||
Utils.showSnackbar(binding.accountTypeListRoot, e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void goToNextActivity(Account account) {
|
|
||||||
if (fromMainActivity) {
|
|
||||||
Intent intent = new Intent();
|
|
||||||
intent.putExtra(ACCOUNT, account);
|
|
||||||
setResult(RESULT_OK, intent);
|
|
||||||
} else {
|
|
||||||
Intent intent = new Intent(getApplicationContext(), MainActivity.class);
|
|
||||||
intent.putExtra(ACCOUNT, account);
|
|
||||||
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,66 +0,0 @@
|
||||||
package com.readrops.app.account;
|
|
||||||
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
|
||||||
|
|
||||||
import com.readrops.app.databinding.AccountTypeItemBinding;
|
|
||||||
import com.readrops.db.entities.account.AccountType;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class AccountTypeListAdapter extends RecyclerView.Adapter<AccountTypeListAdapter.AccountTypeViewHolder> {
|
|
||||||
|
|
||||||
private List<AccountType> accountTypes;
|
|
||||||
private OnItemClickListener listener;
|
|
||||||
|
|
||||||
public AccountTypeListAdapter(OnItemClickListener listener) {
|
|
||||||
this.listener = listener;
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public AccountTypeViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
|
||||||
AccountTypeItemBinding binding = AccountTypeItemBinding.inflate(LayoutInflater.from(parent.getContext()),
|
|
||||||
parent, false);
|
|
||||||
|
|
||||||
return new AccountTypeViewHolder(binding);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onBindViewHolder(@NonNull AccountTypeViewHolder holder, int position) {
|
|
||||||
AccountType accountType = accountTypes.get(position);
|
|
||||||
|
|
||||||
holder.binding.accountTypeName.setText(accountType.getTypeName());
|
|
||||||
holder.binding.accountTypeLogo.setImageResource(accountType.getIconRes());
|
|
||||||
|
|
||||||
holder.binding.getRoot().setOnClickListener(v -> listener.onItemClick(accountType));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getItemCount() {
|
|
||||||
return accountTypes.size();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAccountTypes(List<AccountType> accountTypes) {
|
|
||||||
this.accountTypes = accountTypes;
|
|
||||||
notifyDataSetChanged();
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface OnItemClickListener {
|
|
||||||
void onItemClick(AccountType accountType);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AccountTypeViewHolder extends RecyclerView.ViewHolder {
|
|
||||||
|
|
||||||
private AccountTypeItemBinding binding;
|
|
||||||
|
|
||||||
public AccountTypeViewHolder(AccountTypeItemBinding binding) {
|
|
||||||
super(binding.getRoot());
|
|
||||||
|
|
||||||
this.binding = binding;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,70 +0,0 @@
|
||||||
package com.readrops.app.account;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.net.Uri;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.lifecycle.ViewModel;
|
|
||||||
|
|
||||||
import com.readrops.api.opml.OPMLParser;
|
|
||||||
import com.readrops.app.repositories.ARepository;
|
|
||||||
import com.readrops.db.Database;
|
|
||||||
import com.readrops.db.entities.Feed;
|
|
||||||
import com.readrops.db.entities.Folder;
|
|
||||||
import com.readrops.db.entities.account.Account;
|
|
||||||
|
|
||||||
import org.koin.core.parameter.ParametersHolderKt;
|
|
||||||
import org.koin.java.KoinJavaComponent;
|
|
||||||
|
|
||||||
import java.io.FileNotFoundException;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import io.reactivex.Completable;
|
|
||||||
import io.reactivex.Single;
|
|
||||||
|
|
||||||
public class AccountViewModel extends ViewModel {
|
|
||||||
|
|
||||||
private ARepository repository;
|
|
||||||
private final Database database;
|
|
||||||
|
|
||||||
public AccountViewModel(@NonNull Database database) {
|
|
||||||
this.database = database;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setAccount(Account account) {
|
|
||||||
repository = KoinJavaComponent.get(ARepository.class, null,
|
|
||||||
() -> ParametersHolderKt.parametersOf(account));
|
|
||||||
}
|
|
||||||
|
|
||||||
public Completable login(Account account, boolean insert) {
|
|
||||||
setAccount(account);
|
|
||||||
return repository.login(account, insert);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Single<Long> insert(Account account) {
|
|
||||||
return database.accountDao().insert(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Completable update(Account account) {
|
|
||||||
return database.accountDao().update(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Completable delete(Account account) {
|
|
||||||
return database.accountDao().delete(account);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Single<Integer> getAccountCount() {
|
|
||||||
return database.accountDao().getAccountCount();
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
public Single<Map<Folder, List<Feed>>> getFoldersWithFeeds() {
|
|
||||||
return repository.getFoldersWithFeeds();
|
|
||||||
}
|
|
||||||
|
|
||||||
public Completable parseOPMLFile(Uri uri, Context context) throws FileNotFoundException {
|
|
||||||
return OPMLParser.read(context.getContentResolver().openInputStream(uri))
|
|
||||||
.flatMapCompletable(foldersAndFeeds -> repository.insertOPMLFoldersAndFeeds(foldersAndFeeds));
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,239 +0,0 @@
|
||||||
package com.readrops.app.account;
|
|
||||||
|
|
||||||
import android.content.Intent;
|
|
||||||
import android.os.Bundle;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.util.Patterns;
|
|
||||||
import android.view.KeyEvent;
|
|
||||||
import android.view.MenuItem;
|
|
||||||
import android.view.View;
|
|
||||||
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
|
||||||
|
|
||||||
import com.readrops.app.R;
|
|
||||||
import com.readrops.app.databinding.ActivityAddAccountBinding;
|
|
||||||
import com.readrops.app.itemslist.MainActivity;
|
|
||||||
import com.readrops.app.utils.SharedPreferencesManager;
|
|
||||||
import com.readrops.app.utils.Utils;
|
|
||||||
import com.readrops.db.entities.account.Account;
|
|
||||||
import com.readrops.db.entities.account.AccountType;
|
|
||||||
|
|
||||||
import io.reactivex.CompletableObserver;
|
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.disposables.Disposable;
|
|
||||||
import io.reactivex.schedulers.Schedulers;
|
|
||||||
|
|
||||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
|
||||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_TYPE;
|
|
||||||
import static com.readrops.app.utils.ReadropsKeys.EDIT_ACCOUNT;
|
|
||||||
|
|
||||||
import org.koin.android.compat.ViewModelCompat;
|
|
||||||
|
|
||||||
public class AddAccountActivity extends AppCompatActivity {
|
|
||||||
|
|
||||||
private static final String TAG = AddAccountActivity.class.getSimpleName();
|
|
||||||
|
|
||||||
private ActivityAddAccountBinding binding;
|
|
||||||
private AccountViewModel viewModel;
|
|
||||||
|
|
||||||
private AccountType accountType;
|
|
||||||
private boolean forwardResult, editAccount;
|
|
||||||
|
|
||||||
private Account accountToEdit;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
|
||||||
super.onCreate(savedInstanceState);
|
|
||||||
|
|
||||||
binding = ActivityAddAccountBinding.inflate(getLayoutInflater());
|
|
||||||
setContentView(binding.getRoot());
|
|
||||||
|
|
||||||
viewModel = ViewModelCompat.getViewModel(this, AccountViewModel.class);
|
|
||||||
|
|
||||||
accountType = getIntent().getParcelableExtra(ACCOUNT_TYPE);
|
|
||||||
|
|
||||||
int flag = getIntent().getFlags();
|
|
||||||
forwardResult = flag == Intent.FLAG_ACTIVITY_FORWARD_RESULT;
|
|
||||||
|
|
||||||
accountToEdit = getIntent().getParcelableExtra(EDIT_ACCOUNT);
|
|
||||||
|
|
||||||
if (forwardResult || accountToEdit != null)
|
|
||||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
|
||||||
|
|
||||||
if (accountToEdit != null) {
|
|
||||||
editAccount = true;
|
|
||||||
fillFields();
|
|
||||||
} else {
|
|
||||||
binding.providerImage.setImageResource(accountType.getIconRes());
|
|
||||||
binding.providerName.setText(accountType.getTypeName());
|
|
||||||
binding.addAccountName.setText(accountType.getTypeName());
|
|
||||||
|
|
||||||
if (accountType == AccountType.FRESHRSS) {
|
|
||||||
binding.addAccountPasswordLayout.setHelperText(getString(R.string.password_helper));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void createAccount(View view) {
|
|
||||||
if (fieldsAreValid()) {
|
|
||||||
String url = binding.addAccountUrl.getText().toString().trim();
|
|
||||||
String name = binding.addAccountName.getText().toString().trim();
|
|
||||||
String login = binding.addAccountLogin.getText().toString().trim();
|
|
||||||
String password = binding.addAccountPassword.getText().toString().trim();
|
|
||||||
|
|
||||||
if (!(url.toLowerCase().contains(Utils.HTTP_PREFIX) || url.toLowerCase().contains(Utils.HTTPS_PREFIX))) {
|
|
||||||
url = Utils.HTTPS_PREFIX + url;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (editAccount) {
|
|
||||||
accountToEdit.setUrl(url);
|
|
||||||
accountToEdit.setAccountName(name);
|
|
||||||
accountToEdit.setLogin(login);
|
|
||||||
accountToEdit.setPassword(password);
|
|
||||||
|
|
||||||
updateAccount();
|
|
||||||
} else {
|
|
||||||
Account account = new Account(url, name, accountType);
|
|
||||||
account.setLogin(login);
|
|
||||||
account.setPassword(password);
|
|
||||||
|
|
||||||
viewModel.login(account, true)
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(new CompletableObserver() {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
binding.addAccountLoading.setVisibility(View.VISIBLE);
|
|
||||||
binding.addAccountValidate.setEnabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
saveLoginPassword(account);
|
|
||||||
|
|
||||||
if (forwardResult) {
|
|
||||||
Intent intent = new Intent();
|
|
||||||
intent.putExtra(ACCOUNT, account);
|
|
||||||
setResult(RESULT_OK, intent);
|
|
||||||
} else {
|
|
||||||
Intent intent = new Intent(getApplicationContext(), MainActivity.class);
|
|
||||||
intent.putExtra(ACCOUNT, account);
|
|
||||||
startActivity(intent);
|
|
||||||
}
|
|
||||||
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable e) {
|
|
||||||
Log.d(TAG, e.getMessage());
|
|
||||||
binding.addAccountLoading.setVisibility(View.GONE);
|
|
||||||
binding.addAccountValidate.setEnabled(true);
|
|
||||||
|
|
||||||
Utils.showSnackbar(binding.addAccountRoot, e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean fieldsAreValid() {
|
|
||||||
boolean valid = true;
|
|
||||||
|
|
||||||
if (binding.addAccountUrl.getText().toString().trim().isEmpty()) {
|
|
||||||
binding.addAccountUrl.setError(getString(R.string.empty_field));
|
|
||||||
valid = false;
|
|
||||||
} else if (!Patterns.WEB_URL.matcher(binding.addAccountUrl.getText().toString().trim()).matches()) {
|
|
||||||
binding.addAccountUrl.setError(getString(R.string.wrong_url));
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.addAccountName.getText().toString().trim().isEmpty()) {
|
|
||||||
binding.addAccountName.setError(getString(R.string.empty_field));
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.addAccountLogin.getText().toString().trim().isEmpty()) {
|
|
||||||
binding.addAccountLogin.setError(getString(R.string.empty_field));
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (binding.addAccountPassword.getText().toString().trim().isEmpty()) {
|
|
||||||
binding.addAccountPassword.setError(getString(R.string.empty_field));
|
|
||||||
valid = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return valid;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void saveLoginPassword(Account account) {
|
|
||||||
SharedPreferencesManager.writeValue(account.getLoginKey(), account.getLogin());
|
|
||||||
SharedPreferencesManager.writeValue(account.getPasswordKey(), account.getPassword());
|
|
||||||
|
|
||||||
account.setLogin(null);
|
|
||||||
account.setPassword(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void fillFields() {
|
|
||||||
binding.providerImage.setImageResource(accountToEdit.getAccountType().getIconRes());
|
|
||||||
binding.providerName.setText(accountToEdit.getAccountType().getTypeName());
|
|
||||||
|
|
||||||
binding.addAccountUrl.setText(accountToEdit.getUrl());
|
|
||||||
binding.addAccountName.setText(accountToEdit.getAccountName());
|
|
||||||
binding.addAccountLogin.setText(SharedPreferencesManager.readString(accountToEdit.getLoginKey()));
|
|
||||||
binding.addAccountPassword.setText(SharedPreferencesManager.readString(accountToEdit.getPasswordKey()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void updateAccount() {
|
|
||||||
viewModel.login(accountToEdit, false)
|
|
||||||
.doOnError(throwable -> Utils.showSnackbar(binding.addAccountRoot, throwable.getMessage()))
|
|
||||||
.doAfterTerminate(() -> saveLoginPassword(accountToEdit))
|
|
||||||
.andThen(viewModel.update(accountToEdit))
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(new CompletableObserver() {
|
|
||||||
@Override
|
|
||||||
public void onSubscribe(Disposable d) {
|
|
||||||
binding.addAccountLoading.setVisibility(View.VISIBLE);
|
|
||||||
binding.addAccountValidate.setEnabled(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onComplete() {
|
|
||||||
finish();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onError(Throwable e) {
|
|
||||||
binding.addAccountLoading.setVisibility(View.GONE);
|
|
||||||
binding.addAccountValidate.setEnabled(true);
|
|
||||||
|
|
||||||
Utils.showSnackbar(binding.addAccountRoot, e.getMessage());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onOptionsItemSelected(MenuItem item) {
|
|
||||||
switch (item.getItemId()) {
|
|
||||||
case android.R.id.home:
|
|
||||||
finish();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onOptionsItemSelected(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onKeyUp(int keyCode, KeyEvent event) {
|
|
||||||
switch (keyCode) {
|
|
||||||
case KeyEvent.KEYCODE_ENTER:
|
|
||||||
createAccount(null);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onKeyUp(keyCode, event);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,56 @@
|
||||||
|
package com.readrops.app.account
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.readrops.app.R
|
||||||
|
import com.readrops.app.util.components.dialog.BaseDialog
|
||||||
|
import com.readrops.app.util.theme.spacing
|
||||||
|
|
||||||
|
enum class OPML {
|
||||||
|
IMPORT,
|
||||||
|
EXPORT
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OPMLChoiceDialog(
|
||||||
|
onChoice: (OPML) -> Unit,
|
||||||
|
onDismiss: () -> Unit
|
||||||
|
) {
|
||||||
|
BaseDialog(
|
||||||
|
title = stringResource(id = R.string.opml_import_export),
|
||||||
|
icon = painterResource(id = R.drawable.ic_import_export),
|
||||||
|
onDismiss = onDismiss
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onChoice(OPML.IMPORT) }
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.opml_import),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onChoice(OPML.EXPORT) }
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.opml_export),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
package com.readrops.app.account
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.readrops.app.R
|
||||||
|
import com.readrops.app.util.components.dialog.BaseDialog
|
||||||
|
import com.readrops.app.util.components.RefreshIndicator
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun OPMLImportProgressDialog(
|
||||||
|
currentFeed: String,
|
||||||
|
feedCount: Int,
|
||||||
|
feedMax: Int,
|
||||||
|
) {
|
||||||
|
BaseDialog(
|
||||||
|
title = stringResource(id = R.string.opml_import),
|
||||||
|
icon = painterResource(R.drawable.ic_import_export),
|
||||||
|
onDismiss = {}
|
||||||
|
) {
|
||||||
|
RefreshIndicator(
|
||||||
|
currentFeed = currentFeed,
|
||||||
|
feedCount = feedCount,
|
||||||
|
feedMax = feedMax
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,237 @@
|
||||||
|
package com.readrops.app.account.credentials
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
|
import androidx.compose.ui.text.input.VisualTransformation
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import cafe.adriel.voyager.koin.getScreenModel
|
||||||
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import com.readrops.app.R
|
||||||
|
import com.readrops.app.home.HomeScreen
|
||||||
|
import com.readrops.app.util.ErrorMessage
|
||||||
|
import com.readrops.app.util.components.AndroidScreen
|
||||||
|
import com.readrops.app.util.theme.MediumSpacer
|
||||||
|
import com.readrops.app.util.theme.ShortSpacer
|
||||||
|
import com.readrops.app.util.theme.spacing
|
||||||
|
import com.readrops.db.entities.account.Account
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
|
enum class AccountCredentialsScreenMode {
|
||||||
|
NEW_CREDENTIALS,
|
||||||
|
EDIT_CREDENTIALS
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountCredentialsScreen(
|
||||||
|
private val account: Account,
|
||||||
|
private val mode: AccountCredentialsScreenMode
|
||||||
|
) : AndroidScreen() {
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val screenModel =
|
||||||
|
getScreenModel<AccountCredentialsScreenModel>(parameters = { parametersOf(account, mode) })
|
||||||
|
|
||||||
|
val state by screenModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
if (state.exitScreen) {
|
||||||
|
if (mode == AccountCredentialsScreenMode.NEW_CREDENTIALS) {
|
||||||
|
navigator.replaceAll(HomeScreen)
|
||||||
|
} else {
|
||||||
|
navigator.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = if (mode == AccountCredentialsScreenMode.EDIT_CREDENTIALS)
|
||||||
|
stringResource(id = R.string.credentials)
|
||||||
|
else
|
||||||
|
stringResource(id = R.string.new_account)
|
||||||
|
)
|
||||||
|
},
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { navigator.pop() }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Default.ArrowBack,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(paddingValues)
|
||||||
|
.imePadding()
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.background)
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(MaterialTheme.spacing.mediumSpacing)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = painterResource(id = account.accountType!!.iconRes),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
ShortSpacer()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = account.accountType!!.typeName),
|
||||||
|
style = MaterialTheme.typography.headlineMedium
|
||||||
|
)
|
||||||
|
|
||||||
|
MediumSpacer()
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.name,
|
||||||
|
onValueChange = { screenModel.onEvent(Event.NameEvent(it)) },
|
||||||
|
label = { Text(text = stringResource(id = R.string.account_name)) },
|
||||||
|
singleLine = true,
|
||||||
|
isError = state.isNameError,
|
||||||
|
supportingText = { Text(text = state.nameError?.errorText().orEmpty()) },
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
ShortSpacer()
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.url,
|
||||||
|
onValueChange = { screenModel.onEvent(Event.URLEvent(it)) },
|
||||||
|
label = { Text(text = stringResource(id = R.string.account_url)) },
|
||||||
|
singleLine = true,
|
||||||
|
isError = state.isUrlError,
|
||||||
|
supportingText = { Text(text = state.urlError?.errorText().orEmpty()) },
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
ShortSpacer()
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.login,
|
||||||
|
onValueChange = { screenModel.onEvent(Event.LoginEvent(it)) },
|
||||||
|
label = { Text(text = stringResource(id = R.string.login)) },
|
||||||
|
singleLine = true,
|
||||||
|
isError = state.isLoginError,
|
||||||
|
supportingText = { Text(text = state.loginError?.errorText().orEmpty()) },
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
ShortSpacer()
|
||||||
|
|
||||||
|
OutlinedTextField(
|
||||||
|
value = state.password,
|
||||||
|
onValueChange = { screenModel.onEvent(Event.PasswordEvent(it)) },
|
||||||
|
label = { Text(text = stringResource(id = R.string.password)) },
|
||||||
|
trailingIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { screenModel.setPasswordVisibility(!state.isPasswordVisible) }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(
|
||||||
|
id = if (state.isPasswordVisible) {
|
||||||
|
R.drawable.ic_visible_off
|
||||||
|
} else R.drawable.ic_visible
|
||||||
|
),
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
singleLine = true,
|
||||||
|
visualTransformation = if (state.isPasswordVisible)
|
||||||
|
VisualTransformation.None
|
||||||
|
else
|
||||||
|
PasswordVisualTransformation(),
|
||||||
|
isError = state.isPasswordError,
|
||||||
|
supportingText = {
|
||||||
|
Text(
|
||||||
|
text = state.passwordError?.errorText().orEmpty()
|
||||||
|
)
|
||||||
|
},
|
||||||
|
keyboardOptions = KeyboardOptions(
|
||||||
|
keyboardType = KeyboardType.Password,
|
||||||
|
imeAction = ImeAction.Done
|
||||||
|
),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
|
ShortSpacer()
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { screenModel.login() },
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
if (state.isLoginOnGoing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary,
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Text(text = stringResource(id = R.string.validate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.loginException != null) {
|
||||||
|
ShortSpacer()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = ErrorMessage.get(state.loginException!!, LocalContext.current),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,161 @@
|
||||||
|
package com.readrops.app.account.credentials
|
||||||
|
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import android.util.Patterns
|
||||||
|
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||||
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
|
import com.readrops.app.repositories.BaseRepository
|
||||||
|
import com.readrops.app.util.components.TextFieldError
|
||||||
|
import com.readrops.db.Database
|
||||||
|
import com.readrops.db.entities.account.Account
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.get
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
|
class AccountCredentialsScreenModel(
|
||||||
|
private val account: Account,
|
||||||
|
private val mode: AccountCredentialsScreenMode,
|
||||||
|
private val database: Database,
|
||||||
|
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
) : StateScreenModel<AccountCredentialsState>(AccountCredentialsState()), KoinComponent {
|
||||||
|
|
||||||
|
init {
|
||||||
|
if (mode == AccountCredentialsScreenMode.EDIT_CREDENTIALS) {
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(
|
||||||
|
name = account.accountName!!,
|
||||||
|
url = account.url!!,
|
||||||
|
login = account.login!!,
|
||||||
|
password = account.password!!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mutableState.update { it.copy(name = account.accountName!!) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onEvent(event: Event): Unit = with(mutableState) {
|
||||||
|
when (event) {
|
||||||
|
is Event.LoginEvent -> update { it.copy(login = event.value, loginError = null) }
|
||||||
|
is Event.NameEvent -> update { it.copy(name = event.value, nameError = null) }
|
||||||
|
is Event.PasswordEvent -> update { it.copy(password = event.value, passwordError = null) }
|
||||||
|
is Event.URLEvent -> update { it.copy(url = event.value, urlError = null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setPasswordVisibility(isVisible: Boolean) {
|
||||||
|
mutableState.update { it.copy(isPasswordVisible = isVisible) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun login() {
|
||||||
|
if (validateFields()) {
|
||||||
|
mutableState.update { it.copy(isLoginOnGoing = true) }
|
||||||
|
|
||||||
|
with(state.value) {
|
||||||
|
val newAccount = account.copy(
|
||||||
|
url = url,
|
||||||
|
accountName = name,
|
||||||
|
login = login,
|
||||||
|
password = password,
|
||||||
|
accountType = account.accountType,
|
||||||
|
isCurrentAccount = true
|
||||||
|
)
|
||||||
|
|
||||||
|
val repository = get<BaseRepository> { parametersOf(newAccount) }
|
||||||
|
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
try {
|
||||||
|
repository.login(newAccount)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(
|
||||||
|
loginException = e,
|
||||||
|
isLoginOnGoing = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode == AccountCredentialsScreenMode.NEW_CREDENTIALS) {
|
||||||
|
newAccount.id = database.accountDao().insert(newAccount).toInt()
|
||||||
|
|
||||||
|
get<SharedPreferences>().edit()
|
||||||
|
.putString(newAccount.loginKey, newAccount.login)
|
||||||
|
.putString(newAccount.passwordKey, newAccount.password)
|
||||||
|
.apply()
|
||||||
|
} else {
|
||||||
|
database.accountDao().update(newAccount)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableState.update { it.copy(exitScreen = true) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateFields(): Boolean = with(mutableState.value) {
|
||||||
|
var validate = true
|
||||||
|
|
||||||
|
if (url.isEmpty()) {
|
||||||
|
mutableState.update { it.copy(urlError = TextFieldError.EmptyField) }
|
||||||
|
validate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name.isEmpty()) {
|
||||||
|
mutableState.update { it.copy(nameError = TextFieldError.EmptyField) }
|
||||||
|
validate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (login.isEmpty()) {
|
||||||
|
mutableState.update { it.copy(loginError = TextFieldError.EmptyField) }
|
||||||
|
validate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.isEmpty()) {
|
||||||
|
mutableState.update { it.copy(passwordError = TextFieldError.EmptyField) }
|
||||||
|
validate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.isNotEmpty() && !Patterns.WEB_URL.matcher(url).matches()) {
|
||||||
|
mutableState.update { it.copy(urlError = TextFieldError.BadUrl) }
|
||||||
|
validate = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return validate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AccountCredentialsState(
|
||||||
|
val url: String = "https://",
|
||||||
|
val urlError: TextFieldError? = null,
|
||||||
|
val name: String = "",
|
||||||
|
val nameError: TextFieldError? = null,
|
||||||
|
val login: String = "",
|
||||||
|
val loginError: TextFieldError? = null,
|
||||||
|
val password: String = "",
|
||||||
|
val passwordError: TextFieldError? = null,
|
||||||
|
val isPasswordVisible: Boolean = false,
|
||||||
|
val isLoginOnGoing: Boolean = false,
|
||||||
|
val exitScreen: Boolean = false,
|
||||||
|
val loginException: Exception? = null
|
||||||
|
) {
|
||||||
|
val isUrlError = urlError != null
|
||||||
|
|
||||||
|
val isNameError = nameError != null
|
||||||
|
|
||||||
|
val isLoginError = loginError != null
|
||||||
|
|
||||||
|
val isPasswordError = passwordError != null
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class Event(val value: String) {
|
||||||
|
class URLEvent(value: String) : Event(value)
|
||||||
|
class NameEvent(value: String) : Event(value)
|
||||||
|
class LoginEvent(value: String) : Event(value)
|
||||||
|
class PasswordEvent(value: String) : Event(value)
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.readrops.app.account.selection
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.readrops.app.R
|
||||||
|
import com.readrops.app.util.components.dialog.BaseDialog
|
||||||
|
import com.readrops.app.util.components.SelectableImageText
|
||||||
|
import com.readrops.app.util.theme.spacing
|
||||||
|
import com.readrops.db.entities.account.AccountType
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun AccountSelectionDialog(
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onValidate: (AccountType) -> Unit,
|
||||||
|
) {
|
||||||
|
BaseDialog(
|
||||||
|
title = stringResource(R.string.new_account),
|
||||||
|
icon = painterResource(id = R.drawable.ic_add_account),
|
||||||
|
onDismiss = onDismiss
|
||||||
|
) {
|
||||||
|
AccountType.entries.forEach { type ->
|
||||||
|
SelectableImageText(
|
||||||
|
image = adaptiveIconPainterResource(id = type.iconRes),
|
||||||
|
text = stringResource(id = type.typeName),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
padding = MaterialTheme.spacing.shortSpacing,
|
||||||
|
imageSize = 36.dp,
|
||||||
|
onClick = { onValidate(type) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,240 @@
|
||||||
|
package com.readrops.app.account.selection
|
||||||
|
|
||||||
|
import android.graphics.drawable.AdaptiveIconDrawable
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.annotation.DrawableRes
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.SnackbarHost
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.asImageBitmap
|
||||||
|
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||||
|
import androidx.compose.ui.graphics.painter.Painter
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.res.painterResource
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.content.res.ResourcesCompat
|
||||||
|
import androidx.core.graphics.drawable.toBitmap
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import cafe.adriel.voyager.koin.getScreenModel
|
||||||
|
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||||
|
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||||
|
import com.readrops.api.utils.ApiUtils
|
||||||
|
import com.readrops.app.BuildConfig
|
||||||
|
import com.readrops.app.R
|
||||||
|
import com.readrops.app.account.OPMLImportProgressDialog
|
||||||
|
import com.readrops.app.account.credentials.AccountCredentialsScreen
|
||||||
|
import com.readrops.app.account.credentials.AccountCredentialsScreenMode
|
||||||
|
import com.readrops.app.home.HomeScreen
|
||||||
|
import com.readrops.app.util.ErrorMessage
|
||||||
|
import com.readrops.app.util.components.AndroidScreen
|
||||||
|
import com.readrops.app.util.components.SelectableImageText
|
||||||
|
import com.readrops.app.util.theme.LargeSpacer
|
||||||
|
import com.readrops.app.util.theme.MediumSpacer
|
||||||
|
import com.readrops.app.util.theme.ShortSpacer
|
||||||
|
import com.readrops.app.util.theme.spacing
|
||||||
|
import com.readrops.db.entities.account.Account
|
||||||
|
import com.readrops.db.entities.account.AccountType
|
||||||
|
|
||||||
|
class AccountSelectionScreen : AndroidScreen() {
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun Content() {
|
||||||
|
val navigator = LocalNavigator.currentOrThrow
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
val screenModel = getScreenModel<AccountSelectionScreenModel>()
|
||||||
|
val state by screenModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
val opmlImportLauncher =
|
||||||
|
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||||
|
uri?.let { screenModel.parseOPMLFile(uri, context) }
|
||||||
|
}
|
||||||
|
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
|
||||||
|
if (state.showOPMLImportDialog) {
|
||||||
|
OPMLImportProgressDialog(
|
||||||
|
currentFeed = state.currentFeed,
|
||||||
|
feedCount = state.feedCount,
|
||||||
|
feedMax = state.feedMax
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(state.exception) {
|
||||||
|
if (state.exception != null) {
|
||||||
|
snackbarHostState.showSnackbar(ErrorMessage.get(state.exception!!, context))
|
||||||
|
screenModel.resetException()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
when (state.navState) {
|
||||||
|
is NavState.GoToHomeScreen -> {
|
||||||
|
// using replace makes the app crash due to a screen key conflict
|
||||||
|
navigator.replaceAll(HomeScreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
is NavState.GoToAccountCredentialsScreen -> {
|
||||||
|
val accountType =
|
||||||
|
(state.navState as NavState.GoToAccountCredentialsScreen).accountType
|
||||||
|
val account = Account(
|
||||||
|
accountType = accountType,
|
||||||
|
accountName = stringResource(id = accountType.typeName)
|
||||||
|
)
|
||||||
|
|
||||||
|
navigator.push(
|
||||||
|
AccountCredentialsScreen(account, AccountCredentialsScreenMode.NEW_CREDENTIALS)
|
||||||
|
)
|
||||||
|
screenModel.resetNavState()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.weight(1f)
|
||||||
|
.padding(MaterialTheme.spacing.mediumSpacing)
|
||||||
|
) {
|
||||||
|
Image(
|
||||||
|
painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(64.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
ShortSpacer()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.app_name),
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
)
|
||||||
|
|
||||||
|
LargeSpacer()
|
||||||
|
|
||||||
|
Card {
|
||||||
|
Column {
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.choose_account),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.padding(top = MaterialTheme.spacing.mediumSpacing)
|
||||||
|
)
|
||||||
|
|
||||||
|
MediumSpacer()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(id = R.string.local),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
|
||||||
|
)
|
||||||
|
|
||||||
|
SelectableImageText(
|
||||||
|
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
|
||||||
|
text = stringResource(id = AccountType.LOCAL.typeName),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
padding = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
imageSize = 24.dp,
|
||||||
|
onClick = { screenModel.createAccount(AccountType.LOCAL) }
|
||||||
|
)
|
||||||
|
|
||||||
|
SelectableImageText(
|
||||||
|
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
|
||||||
|
text = stringResource(id = R.string.opml_import),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
padding = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
imageSize = 24.dp,
|
||||||
|
onClick = { opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) }
|
||||||
|
)
|
||||||
|
|
||||||
|
MediumSpacer()
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.external),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
|
||||||
|
)
|
||||||
|
|
||||||
|
AccountType.entries.filter { it != AccountType.LOCAL }
|
||||||
|
.forEach { accountType ->
|
||||||
|
SelectableImageText(
|
||||||
|
image = adaptiveIconPainterResource(id = accountType.iconRes),
|
||||||
|
text = stringResource(id = accountType.typeName),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
imageSize = 24.dp,
|
||||||
|
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
padding = MaterialTheme.spacing.mediumSpacing,
|
||||||
|
onClick = { screenModel.createAccount(accountType) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "v${BuildConfig.VERSION_NAME}",
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterHorizontally)
|
||||||
|
.padding(bottom = MaterialTheme.spacing.veryShortSpacing)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// from https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448
|
||||||
|
@Composable
|
||||||
|
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
|
||||||
|
val res = LocalContext.current.resources
|
||||||
|
val theme = LocalContext.current.theme
|
||||||
|
|
||||||
|
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
// Android O supports adaptive icons, try loading this first (even though this is least likely to be the format).
|
||||||
|
val adaptiveIcon = ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable
|
||||||
|
if (adaptiveIcon != null) {
|
||||||
|
BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap())
|
||||||
|
} else {
|
||||||
|
// We couldn't load the drawable as an Adaptive Icon, just use painterResource
|
||||||
|
painterResource(id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// We're not on Android O or later, just use painterResource
|
||||||
|
painterResource(id)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,133 @@
|
||||||
|
package com.readrops.app.account.selection
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||||
|
import cafe.adriel.voyager.core.model.screenModelScope
|
||||||
|
import com.readrops.api.opml.OPMLParser
|
||||||
|
import com.readrops.app.repositories.BaseRepository
|
||||||
|
import com.readrops.db.Database
|
||||||
|
import com.readrops.db.entities.Feed
|
||||||
|
import com.readrops.db.entities.Folder
|
||||||
|
import com.readrops.db.entities.account.Account
|
||||||
|
import com.readrops.db.entities.account.AccountType
|
||||||
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.update
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.koin.core.component.KoinComponent
|
||||||
|
import org.koin.core.component.get
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
|
||||||
|
class AccountSelectionScreenModel(
|
||||||
|
private val database: Database,
|
||||||
|
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||||
|
) : StateScreenModel<AccountSelectionState>(AccountSelectionState()), KoinComponent {
|
||||||
|
|
||||||
|
fun accountExists(): Boolean {
|
||||||
|
val accountCount = runBlocking {
|
||||||
|
database.accountDao().selectAccountCount()
|
||||||
|
}
|
||||||
|
|
||||||
|
return accountCount > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createAccount(accountType: AccountType) {
|
||||||
|
if (accountType == AccountType.LOCAL) {
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
createLocalAccount()
|
||||||
|
mutableState.update { it.copy(navState = NavState.GoToHomeScreen) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(navState = NavState.GoToAccountCredentialsScreen(accountType))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetNavState() {
|
||||||
|
mutableState.update { it.copy(navState = NavState.Idle) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun createLocalAccount(): Account {
|
||||||
|
val context = get<Context>()
|
||||||
|
val account = Account(
|
||||||
|
url = null,
|
||||||
|
accountName = context.getString(AccountType.LOCAL.typeName),
|
||||||
|
accountType = AccountType.LOCAL,
|
||||||
|
isCurrentAccount = true
|
||||||
|
)
|
||||||
|
|
||||||
|
account.id = database.accountDao().insert(account).toInt()
|
||||||
|
return account
|
||||||
|
}
|
||||||
|
|
||||||
|
fun parseOPMLFile(uri: Uri, context: Context) {
|
||||||
|
screenModelScope.launch(dispatcher) {
|
||||||
|
val foldersAndFeeds: Map<Folder?, List<Feed>>
|
||||||
|
|
||||||
|
try {
|
||||||
|
val stream = context.contentResolver.openInputStream(uri)
|
||||||
|
if (stream == null) {
|
||||||
|
mutableState.update { it.copy(exception = NoSuchFileException(uri.toFile())) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
foldersAndFeeds = OPMLParser.read(stream)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
mutableState.update { it.copy(exception = e) }
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(
|
||||||
|
showOPMLImportDialog = true,
|
||||||
|
currentFeed = foldersAndFeeds.values.first().first().name!!,
|
||||||
|
feedCount = 0,
|
||||||
|
feedMax = foldersAndFeeds.values.flatten().size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val account = createLocalAccount()
|
||||||
|
val repository = get<BaseRepository> { parametersOf(account) }
|
||||||
|
|
||||||
|
repository.insertOPMLFoldersAndFeeds(
|
||||||
|
foldersAndFeeds = foldersAndFeeds,
|
||||||
|
onUpdate = { feed ->
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(
|
||||||
|
currentFeed = feed.name!!,
|
||||||
|
feedCount = it.feedCount + 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
mutableState.update {
|
||||||
|
it.copy(
|
||||||
|
showOPMLImportDialog = false,
|
||||||
|
navState = NavState.GoToHomeScreen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetException() = mutableState.update { it.copy(exception = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AccountSelectionState(
|
||||||
|
val showOPMLImportDialog: Boolean = false,
|
||||||
|
val navState: NavState = NavState.Idle,
|
||||||
|
val exception: Exception? = null,
|
||||||
|
val currentFeed: String = "",
|
||||||
|
val feedCount: Int = 0,
|
||||||
|
val feedMax: Int = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed class NavState {
|
||||||
|
data object Idle : NavState()
|
||||||
|
data object GoToHomeScreen : NavState()
|
||||||
|
class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState()
|
||||||
|
}
|
|
@ -1,51 +0,0 @@
|
||||||
package com.readrops.app.addfeed;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.view.LayoutInflater;
|
|
||||||
import android.view.View;
|
|
||||||
import android.view.ViewGroup;
|
|
||||||
import android.widget.ArrayAdapter;
|
|
||||||
import android.widget.ImageView;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
|
|
||||||
import com.readrops.app.R;
|
|
||||||
import com.readrops.db.entities.account.Account;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
public class AccountArrayAdapter extends ArrayAdapter<Account> {
|
|
||||||
|
|
||||||
public AccountArrayAdapter(@NonNull Context context, @NonNull List<Account> objects) {
|
|
||||||
super(context, 0, objects);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
|
|
||||||
return createItemView(position, convertView, parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
@NonNull
|
|
||||||
@Override
|
|
||||||
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
|
|
||||||
return createItemView(position, convertView, parent);
|
|
||||||
}
|
|
||||||
|
|
||||||
private View createItemView(int position, View convertView, ViewGroup parent) {
|
|
||||||
if (convertView == null) {
|
|
||||||
convertView = LayoutInflater.from(getContext()).inflate(R.layout.account_type_item, parent, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
Account account = getItem(position);
|
|
||||||
|
|
||||||
ImageView accountIcon = convertView.findViewById(R.id.account_type_logo);
|
|
||||||
TextView accountName = convertView.findViewById(R.id.account_type_name);
|
|
||||||
|
|
||||||
accountIcon.setImageResource(account.getAccountType().getIconRes());
|
|
||||||
accountName.setText(account.getAccountType().getTypeName());
|
|
||||||
|
|
||||||
return convertView;
|
|
||||||
}
|
|
||||||
}
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue