Merge remote-tracking branch 'origin/develop' into feature/fever_api

This commit is contained in:
Shinokuni 2024-08-06 18:40:02 +02:00
commit 04820cd700
387 changed files with 17074 additions and 13078 deletions

View File

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

2
.gitignore vendored
View File

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

View File

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

54
api/build.gradle.kts Normal file
View File

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

View File

@ -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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/"
}
}

View File

@ -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/"

View File

@ -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(),
) )

View File

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

View File

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

View File

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

View File

@ -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/"
}
}

View File

@ -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(),
)

View File

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

View File

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

View File

@ -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/"
}
}

View File

@ -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(),
)

View File

@ -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 = ""

View File

@ -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 = ""

View File

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

View File

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

View File

@ -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 =

View File

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

View File

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

View File

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

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,260 @@
package com.readrops.api.services.freshrss
import com.readrops.api.TestUtils
import com.readrops.api.apiModule
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.koin.core.qualifier.named
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.test.get
import retrofit2.Retrofit
import retrofit2.converter.moshi.MoshiConverterFactory
import java.net.HttpURLConnection
import java.net.URLEncoder
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class FreshRSSDataSourceTest : KoinTest {
private lateinit var freshRSSDataSource: 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") }
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

@ -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",

View File

@ -0,0 +1,3 @@
SID=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a
LSID=null
Auth=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a

View File

@ -0,0 +1 @@
PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg

View File

@ -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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36826210">3&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36813688">1&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36797650">26&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36827034">1&nbsp;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&amp;how=up&amp;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&#x27;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&amp;goto=news">hide</a> | <a href="item?id=36823565">20&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823605">73&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36826177">10&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824595">23&nbsp;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&amp;how=up&amp;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&#x27;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&amp;goto=news">hide</a> | <a href="item?id=36825992">3&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825345">1&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823516">66&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824450">93&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825481">15&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823375">22&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823524">73&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824607">43&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825913">24&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36797471">73&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825204">72&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36822880">58&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36803767">18&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824330">11&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36826111">121&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36784114">75&nbsp;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&amp;how=up&amp;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">Hokusais 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&amp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823723">107&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824856">55&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36822530">24&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36783937">57&nbsp;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&amp;how=up&amp;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&amp;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>

View File

@ -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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36826210">3&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36813688">1&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36797650">26&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36827034">1&nbsp;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&amp;how=up&amp;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&#x27;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&amp;goto=news">hide</a> | <a href="item?id=36823565">20&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823605">73&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36826177">10&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824595">23&nbsp;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&amp;how=up&amp;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&#x27;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&amp;goto=news">hide</a> | <a href="item?id=36825992">3&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825345">1&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823516">66&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824450">93&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825481">15&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823375">22&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823524">73&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824607">43&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825913">24&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36797471">73&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825204">72&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36822880">58&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36803767">18&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824330">11&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36826111">121&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36784114">75&nbsp;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&amp;how=up&amp;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">Hokusais 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&amp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823723">107&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824856">55&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36822530">24&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36783937">57&nbsp;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&amp;how=up&amp;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&amp;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>

View File

@ -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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36826210">3&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36813688">1&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36797650">26&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36827034">1&nbsp;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&amp;how=up&amp;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&#x27;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&amp;goto=news">hide</a> | <a href="item?id=36823565">20&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823605">73&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36826177">10&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824595">23&nbsp;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&amp;how=up&amp;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&#x27;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&amp;goto=news">hide</a> | <a href="item?id=36825992">3&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825345">1&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823516">66&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824450">93&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825481">15&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823375">22&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823524">73&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824607">43&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825913">24&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36797471">73&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36825204">72&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36822880">58&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36803767">18&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824330">11&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36826111">121&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36784114">75&nbsp;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&amp;how=up&amp;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">Hokusais 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&amp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36823723">107&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36824856">55&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36822530">24&nbsp;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&amp;how=up&amp;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&amp;goto=news">hide</a> | <a href="item?id=36783937">57&nbsp;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&amp;how=up&amp;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&amp;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>

2
app/.gitignore vendored
View File

@ -1 +1 @@
/build /build

View File

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

90
app/build.gradle.kts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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