mirror of https://github.com/readrops/Readrops.git
Merge remote-tracking branch 'origin/develop' into feature/fever_api
This commit is contained in:
commit
04820cd700
|
@ -11,21 +11,27 @@ on:
|
|||
jobs:
|
||||
build:
|
||||
|
||||
runs-on: macos-latest
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: set up JDK 1.8
|
||||
uses: actions/setup-java@v1
|
||||
- name: set up JDK 1.17
|
||||
uses: actions/setup-java@v3
|
||||
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
|
||||
uses: ReactiveCircus/android-emulator-runner@v2.20.0
|
||||
uses: ReactiveCircus/android-emulator-runner@v2.30.1
|
||||
with:
|
||||
api-level: 29
|
||||
script: ./gradlew clean build connectedCheck jacocoFullReport
|
||||
script: ./gradlew clean build connectedCheck
|
||||
- uses: codecov/codecov-action@v2.1.0
|
||||
with:
|
||||
files: ./build/reports/jacoco/jacocoFullReport.xml
|
||||
fail_ci_if_error: true
|
||||
fail_ci_if_error: false
|
||||
verbose: true
|
|
@ -135,3 +135,5 @@ Gemfile
|
|||
mapping/
|
||||
|
||||
**/*.exec
|
||||
|
||||
.kotlin/
|
||||
|
|
|
@ -1,83 +0,0 @@
|
|||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
android {
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
androidTest.assets.srcDirs += files("$projectDir/androidTest/assets".toString())
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
|
||||
debug {
|
||||
minifyEnabled false
|
||||
testCoverageEnabled true
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation project(':db')
|
||||
|
||||
testImplementation 'junit:junit:4.13'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
testImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
|
||||
|
||||
testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
|
||||
testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
|
||||
|
||||
implementation 'com.gitlab.mvysny.konsume-xml:konsume-xml:1.0'
|
||||
implementation 'org.redundent:kotlin-xml-builder:1.7.3'
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.1'
|
||||
|
||||
implementation('com.squareup.retrofit2:retrofit:2.9.0') {
|
||||
exclude group: 'com.squareup.okhttp3', module: 'okhttp3'
|
||||
}
|
||||
implementation('com.squareup.retrofit2:converter-moshi:2.9.0') {
|
||||
exclude group: 'com.squareup.moshi', module: 'moshi'
|
||||
}
|
||||
|
||||
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.9.0'
|
||||
|
||||
implementation 'com.squareup.moshi:moshi:1.12.0'
|
||||
|
||||
api 'io.reactivex.rxjava2:rxandroid:2.1.1'
|
||||
api 'org.jsoup:jsoup:1.13.1'
|
||||
|
||||
debugApi 'com.chimerapps.niddler:niddler:1.5.5'
|
||||
releaseApi 'com.chimerapps.niddler:niddler-noop:1.5.5'
|
||||
|
||||
api "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.2"
|
||||
api "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.1"
|
||||
api "org.jetbrains.kotlinx:kotlinx-coroutines-rx2:1.5.1"
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
plugins {
|
||||
id("com.android.library")
|
||||
kotlin("android")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.readrops.api"
|
||||
|
||||
sourceSets {
|
||||
getByName("androidTest") {
|
||||
assets.srcDirs("$projectDir/androidTest/assets")
|
||||
}
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = listOf("-Xstring-concat=inline")
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":db"))
|
||||
|
||||
coreLibraryDesugaring(libs.jdk.desugar)
|
||||
|
||||
testImplementation(libs.junit4)
|
||||
|
||||
implementation(libs.coroutines.core)
|
||||
testImplementation(libs.coroutines.test)
|
||||
|
||||
implementation(platform(libs.koin.bom))
|
||||
implementation(libs.bundles.koin)
|
||||
//testImplementation(libs.bundles.kointest)
|
||||
// I don't know why but those dependencies are unreachable when accessed directly from version catalog
|
||||
testImplementation("io.insert-koin:koin-test:${libs.versions.koin.bom.get()}")
|
||||
testImplementation("io.insert-koin:koin-test-junit4:${libs.versions.koin.bom.get()}")
|
||||
|
||||
implementation(libs.konsumexml)
|
||||
implementation(libs.kotlinxmlbuilder)
|
||||
|
||||
implementation(libs.okhttp)
|
||||
testImplementation(libs.okhttp.mockserver)
|
||||
|
||||
implementation(libs.bundles.retrofit) {
|
||||
exclude("com.squareup.okhttp3", "okhttp3")
|
||||
exclude("com.squareup.moshi", "moshi")
|
||||
}
|
||||
|
||||
implementation(libs.moshi)
|
||||
implementation(libs.jsoup)
|
||||
}
|
|
@ -1,5 +1,4 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.readrops.api">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- for tests only -->
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.readrops.api">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
</manifest>
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package com.readrops.api
|
||||
|
||||
import com.chimerapps.niddler.interceptor.okhttp.NiddlerOkHttpInterceptor
|
||||
import com.readrops.api.localfeed.LocalRSSDataSource
|
||||
import com.readrops.api.services.Credentials
|
||||
import com.readrops.api.services.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.freshrss.FreshRSSDataSource
|
||||
import com.readrops.api.services.freshrss.FreshRSSService
|
||||
import com.readrops.api.services.freshrss.adapters.*
|
||||
import com.readrops.api.services.nextcloudnews.NextNewsDataSource
|
||||
import com.readrops.api.services.nextcloudnews.NextNewsService
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFeedsAdapter
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFoldersAdapter
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsItemsAdapter
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSFeedsAdapter
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSFoldersAdapter
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSItemsAdapter
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSItemsIdsAdapter
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfoAdapter
|
||||
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.ErrorInterceptor
|
||||
import com.readrops.db.entities.Item
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
|
@ -22,7 +26,6 @@ import okhttp3.OkHttpClient
|
|||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
|
@ -30,15 +33,18 @@ val apiModule = module {
|
|||
|
||||
single {
|
||||
OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.addInterceptor(get<AuthInterceptor>())
|
||||
.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
|
||||
.build()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.MINUTES)
|
||||
.addInterceptor(get<AuthInterceptor>())
|
||||
.addInterceptor(get<ErrorInterceptor>())
|
||||
//.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
|
||||
.build()
|
||||
}
|
||||
|
||||
single { AuthInterceptor() }
|
||||
|
||||
single { ErrorInterceptor() }
|
||||
|
||||
single { LocalRSSDataSource(get()) }
|
||||
|
||||
//region freshrss
|
||||
|
@ -47,12 +53,11 @@ val apiModule = module {
|
|||
|
||||
factory { (credentials: Credentials) ->
|
||||
Retrofit.Builder()
|
||||
.baseUrl(credentials.url)
|
||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
||||
.client(get())
|
||||
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
|
||||
.build()
|
||||
.create(FreshRSSService::class.java)
|
||||
.baseUrl(credentials.url)
|
||||
.client(get())
|
||||
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
|
||||
.build()
|
||||
.create(FreshRSSService::class.java)
|
||||
}
|
||||
|
||||
single(named("freshrssMoshi")) {
|
||||
|
@ -69,23 +74,22 @@ val apiModule = module {
|
|||
|
||||
//region nextcloud news
|
||||
|
||||
factory { params -> NextNewsDataSource(get(parameters = { params })) }
|
||||
factory { params -> NextcloudNewsDataSource(get(parameters = { params })) }
|
||||
|
||||
factory { (credentials: Credentials) ->
|
||||
Retrofit.Builder()
|
||||
.baseUrl(credentials.url)
|
||||
.addCallAdapterFactory(RxJava2CallAdapterFactory.create())
|
||||
.client(get())
|
||||
.addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi"))))
|
||||
.build()
|
||||
.create(NextNewsService::class.java)
|
||||
.create(NextcloudNewsService::class.java)
|
||||
}
|
||||
|
||||
single(named("nextcloudNewsMoshi")) {
|
||||
Moshi.Builder()
|
||||
.add(NextNewsFeedsAdapter())
|
||||
.add(NextNewsFoldersAdapter())
|
||||
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextNewsItemsAdapter())
|
||||
.add(NextcloudNewsFeedsAdapter())
|
||||
.add(NextcloudNewsFoldersAdapter())
|
||||
.add(Types.newParameterizedType(List::class.java, Item::class.java), NextcloudNewsItemsAdapter())
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
package com.readrops.api.localfeed
|
||||
|
||||
import android.accounts.NetworkErrorException
|
||||
import androidx.annotation.WorkerThread
|
||||
import com.gitlab.mvysny.konsumexml.Konsumer
|
||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||
import com.readrops.api.localfeed.json.JSONFeedAdapter
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
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.UnknownFormatException
|
||||
import com.readrops.db.entities.Feed
|
||||
|
@ -21,7 +21,6 @@ import okio.Buffer
|
|||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import java.io.IOException
|
||||
import java.lang.Exception
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
||||
|
@ -32,7 +31,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
|||
* @param headers request headers
|
||||
* @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
|
||||
fun queryRSSResource(url: String, headers: Headers?): Pair<Feed, List<Item>>? {
|
||||
get<AuthInterceptor>().credentials = null
|
||||
|
@ -46,7 +45,7 @@ class LocalRSSDataSource(private val httpClient: OkHttpClient) : KoinComponent {
|
|||
pair
|
||||
}
|
||||
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)
|
||||
rootKonsumer?.let { type = LocalRSSHelper.guessRSSType(rootKonsumer) }
|
||||
} catch (e: Exception) {
|
||||
throw UnknownFormatException(e.message)
|
||||
close()
|
||||
return false
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ class ATOMFeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
|
|||
"link" -> parseLink(this@allChildrenAutoIgnore, feed)
|
||||
"subtitle" -> description = nullableText()
|
||||
"entry" -> items += itemAdapter.fromXml(this@allChildrenAutoIgnore)
|
||||
else -> skipContents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,13 +4,13 @@ import com.gitlab.mvysny.konsumexml.Konsumer
|
|||
import com.gitlab.mvysny.konsumexml.Names
|
||||
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
|
||||
import com.readrops.api.localfeed.XmlAdapter
|
||||
import com.readrops.api.utils.*
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.nullableText
|
||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||
import com.readrops.db.entities.Item
|
||||
import org.joda.time.LocalDateTime
|
||||
import com.readrops.db.util.DateUtils
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class ATOMItemAdapter : XmlAdapter<Item> {
|
||||
|
||||
|
@ -22,7 +22,7 @@ class ATOMItemAdapter : XmlAdapter<Item> {
|
|||
konsumer.allChildrenAutoIgnore(names) {
|
||||
when (tagName) {
|
||||
"title" -> title = nonNullText()
|
||||
"id" -> guid = nullableText()
|
||||
"id" -> remoteId = nullableText()
|
||||
"updated" -> pubDate = DateUtils.parse(nullableText())
|
||||
"link" -> parseLink(this, this@apply)
|
||||
"author" -> allChildrenAutoIgnore("name") { author = nullableText() }
|
||||
|
@ -35,7 +35,7 @@ class ATOMItemAdapter : XmlAdapter<Item> {
|
|||
|
||||
validateItem(item)
|
||||
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
|
||||
} catch (e: Exception) {
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
package com.readrops.api.localfeed.json
|
||||
|
||||
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||
import com.readrops.api.utils.extensions.nextNullableString
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.util.DateUtils
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonWriter
|
||||
import org.joda.time.LocalDateTime
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class JSONItemsAdapter : JsonAdapter<List<Item>>() {
|
||||
|
||||
|
@ -33,7 +33,7 @@ class JSONItemsAdapter : JsonAdapter<List<Item>>() {
|
|||
while (hasNext()) {
|
||||
with(item) {
|
||||
when (selectName(names)) {
|
||||
0 -> guid = nextNonEmptyString()
|
||||
0 -> remoteId = nextNonEmptyString()
|
||||
1 -> link = nextNonEmptyString()
|
||||
2 -> title = nextNonEmptyString()
|
||||
3 -> contentHtml = nextNullableString()
|
||||
|
|
|
@ -26,6 +26,7 @@ class RSS1FeedAdapter : XmlAdapter<Pair<Feed, List<Item>>> {
|
|||
when (tagName) {
|
||||
"channel" -> parseChannel(this, feed)
|
||||
"item" -> items += itemAdapter.fromXml(this)
|
||||
else -> skipContents()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,13 +5,13 @@ import com.gitlab.mvysny.konsumexml.Names
|
|||
import com.gitlab.mvysny.konsumexml.allChildrenAutoIgnore
|
||||
import com.readrops.api.localfeed.XmlAdapter
|
||||
import com.readrops.api.localfeed.XmlAdapter.Companion.AUTHORS_MAX
|
||||
import com.readrops.api.utils.*
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.nullableText
|
||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||
import com.readrops.db.entities.Item
|
||||
import org.joda.time.LocalDateTime
|
||||
import com.readrops.db.util.DateUtils
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class RSS1ItemAdapter : XmlAdapter<Item> {
|
||||
|
||||
|
@ -40,7 +40,7 @@ class RSS1ItemAdapter : XmlAdapter<Item> {
|
|||
if (item.pubDate == null) item.pubDate = LocalDateTime.now()
|
||||
if (item.link == null) item.link = about
|
||||
?: throw ParseException("RSS1 link or about element is required")
|
||||
item.guid = item.link
|
||||
item.remoteId = item.link
|
||||
|
||||
if (authors.filterNotNull().isNotEmpty()) item.author = authors.filterNotNull()
|
||||
.joinToString(limit = AUTHORS_MAX)
|
||||
|
|
|
@ -1,15 +1,19 @@
|
|||
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.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.extensions.nonNullText
|
||||
import com.readrops.api.utils.extensions.nullableText
|
||||
import com.readrops.api.utils.extensions.nullableTextRecursively
|
||||
import com.readrops.db.entities.Item
|
||||
import org.joda.time.LocalDateTime
|
||||
import com.readrops.db.util.DateUtils
|
||||
import java.time.LocalDateTime
|
||||
|
||||
class RSS2ItemAdapter : XmlAdapter<Item> {
|
||||
|
||||
|
@ -29,7 +33,7 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
|
|||
"dc:creator" -> creators += nullableText()
|
||||
"pubDate" -> pubDate = DateUtils.parse(nullableText())
|
||||
"dc:date" -> pubDate = DateUtils.parse(nullableText())
|
||||
"guid" -> guid = nullableText()
|
||||
"guid" -> remoteId = nullableText()
|
||||
"description" -> description = nullableTextRecursively()
|
||||
"content:encoded" -> content = nullableTextRecursively()
|
||||
"enclosure" -> parseEnclosure(this, item = this@apply)
|
||||
|
@ -81,7 +85,7 @@ class RSS2ItemAdapter : XmlAdapter<Item> {
|
|||
validateItem(this)
|
||||
|
||||
if (pubDate == null) pubDate = LocalDateTime.now()
|
||||
if (guid == null) guid = link
|
||||
if (remoteId == null) remoteId = link
|
||||
if (author == null && creators.filterNotNull().isNotEmpty())
|
||||
author = creators.filterNotNull().joinToString(limit = AUTHORS_MAX)
|
||||
}
|
||||
|
|
|
@ -1,59 +1,45 @@
|
|||
package com.readrops.api.opml
|
||||
|
||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.Single
|
||||
import org.redundent.kotlin.xml.xml
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
object OPMLParser {
|
||||
|
||||
@JvmStatic
|
||||
fun read(stream: InputStream): Single<Map<Folder?, List<Feed>>> {
|
||||
return Single.create { emitter ->
|
||||
try {
|
||||
val adapter = OPMLAdapter()
|
||||
val opml = adapter.fromXml(stream.konsumeXml())
|
||||
suspend fun read(stream: InputStream): Map<Folder?, List<Feed>> {
|
||||
try {
|
||||
val adapter = OPMLAdapter()
|
||||
val opml = adapter.fromXml(stream.konsumeXml())
|
||||
|
||||
emitter.onSuccess(opml)
|
||||
} catch (e: Exception) {
|
||||
emitter.onError(e)
|
||||
}
|
||||
stream.close()
|
||||
return opml
|
||||
} catch (e: Exception) {
|
||||
throw ParseException(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream): Completable {
|
||||
return Completable.create { emitter ->
|
||||
val opml = xml("opml") {
|
||||
attribute("version", "2.0")
|
||||
suspend fun write(foldersAndFeeds: Map<Folder?, List<Feed>>, outputStream: OutputStream) {
|
||||
val opml = xml("opml") {
|
||||
attribute("version", "2.0")
|
||||
|
||||
"head" {
|
||||
-"Subscriptions"
|
||||
}
|
||||
"head" {
|
||||
-"Subscriptions"
|
||||
}
|
||||
|
||||
"body" {
|
||||
for (folderAndFeeds in foldersAndFeeds) {
|
||||
if (folderAndFeeds.key != null) { // feeds with folder
|
||||
"outline" {
|
||||
folderAndFeeds.key?.name?.let {
|
||||
attribute("title", 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) }
|
||||
}
|
||||
}
|
||||
"body" {
|
||||
for (folderAndFeeds in foldersAndFeeds) {
|
||||
if (folderAndFeeds.key != null) { // feeds with folder
|
||||
"outline" {
|
||||
folderAndFeeds.key?.name?.let {
|
||||
attribute("title", it)
|
||||
attribute("text", it)
|
||||
}
|
||||
} else {
|
||||
for (feed in folderAndFeeds.value) { // feeds without folder
|
||||
|
||||
for (feed in folderAndFeeds.value) {
|
||||
"outline" {
|
||||
feed.name?.let { attribute("title", it) }
|
||||
attribute("xmlUrl", feed.url!!)
|
||||
|
@ -61,14 +47,21 @@ object OPMLParser {
|
|||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (feed in folderAndFeeds.value) { // feeds without folder
|
||||
"outline" {
|
||||
feed.name?.let { attribute("title", it) }
|
||||
attribute("xmlUrl", feed.url!!)
|
||||
feed.siteUrl?.let { attribute("htmlUrl", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
outputStream.write(opml.toString().toByteArray())
|
||||
outputStream.flush()
|
||||
|
||||
emitter.onComplete()
|
||||
}
|
||||
|
||||
outputStream.write(opml.toString().toByteArray())
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
}
|
||||
}
|
|
@ -3,8 +3,8 @@ package com.readrops.api.services
|
|||
import com.readrops.api.services.fever.FeverCredentials
|
||||
import com.readrops.api.services.freshrss.FreshRSSCredentials
|
||||
import com.readrops.api.services.freshrss.FreshRSSService
|
||||
import com.readrops.api.services.nextcloudnews.NextNewsCredentials
|
||||
import com.readrops.api.services.nextcloudnews.NextNewsService
|
||||
import com.readrops.api.services.nextcloudnews.NextcloudNewsCredentials
|
||||
import com.readrops.api.services.nextcloudnews.NextcloudNewsService
|
||||
import com.readrops.db.entities.account.Account
|
||||
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!!)
|
||||
|
||||
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.FEVER -> FeverCredentials(account.login, account.password, account.url + endPoint)
|
||||
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 {
|
||||
return when (accountType) {
|
||||
AccountType.FRESHRSS -> FreshRSSService.END_POINT
|
||||
AccountType.NEXTCLOUD_NEWS -> NextNewsService.END_POINT
|
||||
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsService.END_POINT
|
||||
AccountType.FEVER -> ""
|
||||
else -> throw IllegalArgumentException("Unknown account type")
|
||||
}
|
||||
|
|
|
@ -0,0 +1,15 @@
|
|||
package com.readrops.api.services
|
||||
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.Item
|
||||
|
||||
data class DataSourceResult(
|
||||
var items: List<Item> = mutableListOf(),
|
||||
var starredItems: List<Item> = mutableListOf(),
|
||||
var feeds: List<Feed> = listOf(),
|
||||
var folders: List<Folder> = listOf(),
|
||||
var unreadIds: List<String> = listOf(),
|
||||
var readIds: List<String> = listOf(),
|
||||
var starredIds: List<String> = listOf(),
|
||||
)
|
|
@ -1,15 +0,0 @@
|
|||
package com.readrops.api.services
|
||||
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.Item
|
||||
|
||||
class SyncResult(var items: List<Item> = mutableListOf(),
|
||||
var starredItems: List<Item> = mutableListOf(),
|
||||
var feeds: List<Feed> = listOf(),
|
||||
var folders: List<Folder> = listOf(),
|
||||
var unreadIds: List<String>? = null,
|
||||
var readIds: List<String>? = null,
|
||||
var starredIds: List<String>? = null,
|
||||
var isError: Boolean = false
|
||||
)
|
|
@ -7,10 +7,10 @@ import com.readrops.api.utils.extensions.nextNullableString
|
|||
import com.readrops.api.utils.extensions.skipField
|
||||
import com.readrops.api.utils.extensions.toBoolean
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.util.DateUtils
|
||||
import com.squareup.moshi.FromJson
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.ToJson
|
||||
import org.joda.time.LocalDateTime
|
||||
|
||||
class FeverItemsAdapter {
|
||||
|
||||
|
@ -47,7 +47,7 @@ class FeverItemsAdapter {
|
|||
5 -> link = nextNullableString()
|
||||
6 -> isRead = nextInt().toBoolean()
|
||||
7 -> isStarred = nextInt().toBoolean()
|
||||
8 -> pubDate = LocalDateTime(nextLong() * 1000L)
|
||||
8 -> pubDate = DateUtils.fromEpochSeconds(nextLong())
|
||||
else -> skipValue()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,316 +0,0 @@
|
|||
package com.readrops.api.services.freshrss;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.readrops.api.services.SyncResult;
|
||||
import com.readrops.api.services.SyncType;
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo;
|
||||
import com.readrops.db.entities.Feed;
|
||||
import com.readrops.db.entities.Folder;
|
||||
import com.readrops.db.entities.Item;
|
||||
|
||||
import java.io.StringReader;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
import io.reactivex.Completable;
|
||||
import io.reactivex.Single;
|
||||
import okhttp3.MultipartBody;
|
||||
import okhttp3.RequestBody;
|
||||
|
||||
public class FreshRSSDataSource {
|
||||
|
||||
private static final int MAX_ITEMS = 2500;
|
||||
private static final int MAX_STARRED_ITEMS = 1000;
|
||||
|
||||
public static final String GOOGLE_READ = "user/-/state/com.google/read";
|
||||
public static final String GOOGLE_UNREAD = "user/-/state/com.google/unread";
|
||||
public static final String GOOGLE_STARRED = "user/-/state/com.google/starred";
|
||||
public static final String GOOGLE_READING_LIST = "user/-/state/com.google/reading-list";
|
||||
|
||||
private static final String FEED_PREFIX = "feed/";
|
||||
|
||||
private final FreshRSSService api;
|
||||
|
||||
public FreshRSSDataSource(FreshRSSService api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call token API to generate a new token from account credentials
|
||||
*
|
||||
* @param login login
|
||||
* @param password password
|
||||
* @return the generated token
|
||||
*/
|
||||
public Single<String> login(@NonNull String login, @NonNull String password) {
|
||||
RequestBody requestBody = new MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("Email", login)
|
||||
.addFormDataPart("Passwd", password)
|
||||
.build();
|
||||
|
||||
return api.login(requestBody)
|
||||
.flatMap(response -> {
|
||||
Properties properties = new Properties();
|
||||
properties.load(new StringReader(response.string()));
|
||||
|
||||
return Single.just(properties.getProperty("Auth"));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a write token to modify feeds, folders and items on the server
|
||||
*
|
||||
* @return the write token generated by the server
|
||||
*/
|
||||
public Single<String> getWriteToken() {
|
||||
return api.getWriteToken()
|
||||
.flatMap(responseBody -> Single.just(responseBody.string()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve user information : name, email, id, profileId
|
||||
*
|
||||
* @return user information
|
||||
*/
|
||||
public Single<FreshRSSUserInfo> getUserInfo() {
|
||||
return api.getUserInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronize feeds, folders, items and push read/unread items
|
||||
*
|
||||
* @param syncType INITIAL or CLASSIC
|
||||
* @param syncData data to sync (read/unread items ids, lastModified timestamp)
|
||||
* @param writeToken token for making modifications on the server
|
||||
* @return the result of the synchronization
|
||||
*/
|
||||
public Single<SyncResult> sync(@NonNull SyncType syncType, @NonNull FreshRSSSyncData syncData, @NonNull String writeToken) {
|
||||
if (syncType == SyncType.INITIAL_SYNC) {
|
||||
return Single.zip(setItemsReadState(syncData, writeToken).toSingleDefault(""),
|
||||
setItemsStarState(syncData, writeToken).toSingleDefault(""),
|
||||
getFolders(),
|
||||
getFeeds(),
|
||||
getItems(Arrays.asList(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null),
|
||||
getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS), // unread items ids
|
||||
getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS), // starred items ids
|
||||
getStarredItems(MAX_STARRED_ITEMS),
|
||||
(readState, starState, folders, feeds, items, unreadItemsIds, starredItemsIds, starredItems) ->
|
||||
new SyncResult(items, starredItems, feeds, folders, unreadItemsIds, Collections.emptyList(), starredItemsIds, false)
|
||||
);
|
||||
} else {
|
||||
return Single.zip(setItemsReadState(syncData, writeToken).toSingleDefault(""),
|
||||
setItemsStarState(syncData, writeToken).toSingleDefault(""),
|
||||
getFolders(),
|
||||
getFeeds(),
|
||||
getItems(null, MAX_ITEMS, syncData.getLastModified()),
|
||||
getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS), // unread items ids
|
||||
getItemsIds(GOOGLE_UNREAD, GOOGLE_READING_LIST, MAX_ITEMS), // read items ids
|
||||
getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS), // starred items ids
|
||||
(readState, starState, folders, feeds, items, unreadItemsIds, readItemsIds, starredItemsIds) ->
|
||||
new SyncResult(items, Collections.emptyList(), feeds, folders, unreadItemsIds, readItemsIds, starredItemsIds, false)
|
||||
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the feeds folders
|
||||
*
|
||||
* @return the feeds folders
|
||||
*/
|
||||
public Single<List<Folder>> getFolders() {
|
||||
return api.getFolders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the feeds
|
||||
*
|
||||
* @return the feeds
|
||||
*/
|
||||
public Single<List<Feed>> getFeeds() {
|
||||
return api.getFeeds();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the items
|
||||
*
|
||||
* @param excludeTargets type of items to exclude (read items and starred items)
|
||||
* @param max max number of items to fetch
|
||||
* @param lastModified fetch only items created after this timestamp
|
||||
* @return the items
|
||||
*/
|
||||
public Single<List<Item>> getItems(@Nullable List<String> excludeTargets, int max, @Nullable Long lastModified) {
|
||||
return api.getItems(excludeTargets, max, lastModified);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch starred items
|
||||
*
|
||||
* @param max max number of items to fetch
|
||||
* @return items
|
||||
*/
|
||||
public Single<List<Item>> getStarredItems(int max) {
|
||||
return api.getStarredItems(max);
|
||||
}
|
||||
|
||||
public Single<List<String>> getItemsIds(String excludeTarget, String includeTarget, int max) {
|
||||
return api.getItemsIds(excludeTarget, includeTarget, max);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Mark items read or unread
|
||||
*
|
||||
* @param read true for read, false for unread
|
||||
* @param itemIds items ids to mark
|
||||
* @param token token for modifications
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable setItemsReadState(boolean read, @NonNull List<String> itemIds, @NonNull String token) {
|
||||
if (read) {
|
||||
return api.setItemsState(token, GOOGLE_READ, null, itemIds);
|
||||
} else {
|
||||
return api.setItemsState(token, null, GOOGLE_READ, itemIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark items as starred or unstarred
|
||||
*
|
||||
* @param starred true for starred, false for unstarred
|
||||
* @param itemIds items ids to mark
|
||||
* @param token token for modifications
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable setItemsStarState(boolean starred, @NonNull List<String> itemIds, @NonNull String token) {
|
||||
if (starred) {
|
||||
return api.setItemsState(token, GOOGLE_STARRED, null, itemIds);
|
||||
} else {
|
||||
return api.setItemsState(token, null, GOOGLE_STARRED, itemIds);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new feed
|
||||
*
|
||||
* @param token token for modifications
|
||||
* @param feedUrl url of the feed to parse
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable createFeed(@NonNull String token, @NonNull String feedUrl) {
|
||||
return api.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe");
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a feed
|
||||
*
|
||||
* @param token token for modifications
|
||||
* @param feedUrl url of the feed to delete
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable deleteFeed(@NonNull String token, @NonNull String feedUrl) {
|
||||
return api.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe");
|
||||
}
|
||||
|
||||
/**
|
||||
* Update feed title and folder
|
||||
*
|
||||
* @param token token for modifications
|
||||
* @param feedUrl url of the feed to update
|
||||
* @param title new title
|
||||
* @param folderId id of the new folder
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable updateFeed(@NonNull String token, @NonNull String feedUrl, @NonNull String title, @NonNull String folderId) {
|
||||
return api.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new folder
|
||||
*
|
||||
* @param token token for modifications
|
||||
* @param tagName name of the new folder
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable createFolder(@NonNull String token, @NonNull String tagName) {
|
||||
return api.createFolder(token, "user/-/label/" + tagName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update folder name
|
||||
*
|
||||
* @param token token for modifications
|
||||
* @param folderId id of the folder
|
||||
* @param name new folder name
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable updateFolder(@NonNull String token, @NonNull String folderId, @NonNull String name) {
|
||||
return api.updateFolder(token, folderId, "user/-/label/" + name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a folder
|
||||
*
|
||||
* @param token token for modifications
|
||||
* @param folderId id of the folder to delete
|
||||
* @return Completable
|
||||
*/
|
||||
public Completable deleteFolder(@NonNull String token, @NonNull String folderId) {
|
||||
return api.deleteFolder(token, folderId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set items star state
|
||||
*
|
||||
* @param syncData data containing items to mark
|
||||
* @param token token for modifications
|
||||
* @return A concatenation of two completable (read and unread completable)
|
||||
*/
|
||||
private Completable setItemsReadState(@NonNull FreshRSSSyncData syncData, @NonNull String token) {
|
||||
Completable readItemsCompletable;
|
||||
if (syncData.getReadItemsIds().isEmpty()) {
|
||||
readItemsCompletable = Completable.complete();
|
||||
} else {
|
||||
readItemsCompletable = setItemsReadState(true, syncData.getReadItemsIds(), token);
|
||||
}
|
||||
|
||||
Completable unreadItemsCompletable;
|
||||
if (syncData.getUnreadItemsIds().isEmpty()) {
|
||||
unreadItemsCompletable = Completable.complete();
|
||||
} else {
|
||||
unreadItemsCompletable = setItemsReadState(false, syncData.getUnreadItemsIds(), token);
|
||||
}
|
||||
|
||||
return readItemsCompletable.concatWith(unreadItemsCompletable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set items star state
|
||||
*
|
||||
* @param syncData data containing items to mark
|
||||
* @param token token for modifications
|
||||
* @return A concatenation of two completable (starred and unstarred completable)
|
||||
*/
|
||||
private Completable setItemsStarState(@NonNull FreshRSSSyncData syncData, @NonNull String token) {
|
||||
Completable starredItemsCompletable;
|
||||
if (syncData.getStarredItemsIds().isEmpty()) {
|
||||
starredItemsCompletable = Completable.complete();
|
||||
} else {
|
||||
starredItemsCompletable = setItemsStarState(true, syncData.getStarredItemsIds(), token);
|
||||
}
|
||||
|
||||
Completable unstarredItemsCompletable;
|
||||
if (syncData.getUnstarredItemsIds().isEmpty()) {
|
||||
unstarredItemsCompletable = Completable.complete();
|
||||
} else {
|
||||
unstarredItemsCompletable = setItemsStarState(false, syncData.getUnstarredItemsIds(), token);
|
||||
}
|
||||
|
||||
return starredItemsCompletable.concatWith(unstarredItemsCompletable);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,164 @@
|
|||
package com.readrops.api.services.freshrss
|
||||
|
||||
import com.readrops.api.services.DataSourceResult
|
||||
import com.readrops.api.services.SyncType
|
||||
import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
|
||||
import com.readrops.db.entities.Item
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import okhttp3.MultipartBody
|
||||
import java.io.StringReader
|
||||
import java.util.Properties
|
||||
|
||||
class FreshRSSDataSource(private val service: FreshRSSService) {
|
||||
|
||||
suspend fun login(login: String, password: String): String {
|
||||
val requestBody = MultipartBody.Builder()
|
||||
.setType(MultipartBody.FORM)
|
||||
.addFormDataPart("Email", login)
|
||||
.addFormDataPart("Passwd", password)
|
||||
.build()
|
||||
|
||||
val response = service.login(requestBody)
|
||||
|
||||
val properties = Properties()
|
||||
properties.load(StringReader(response.string()))
|
||||
|
||||
response.close()
|
||||
return properties.getProperty("Auth")
|
||||
}
|
||||
|
||||
suspend fun getWriteToken(): String = service.getWriteToken().string()
|
||||
|
||||
suspend fun getUserInfo(): FreshRSSUserInfo = service.userInfo()
|
||||
|
||||
suspend fun synchronize(
|
||||
syncType: SyncType,
|
||||
syncData: FreshRSSSyncData,
|
||||
writeToken: String
|
||||
): DataSourceResult = with(CoroutineScope(Dispatchers.IO)) {
|
||||
return if (syncType == SyncType.INITIAL_SYNC) {
|
||||
DataSourceResult().apply {
|
||||
listOf(
|
||||
async { folders = getFolders() },
|
||||
async { feeds = getFeeds() },
|
||||
async {
|
||||
items = getItems(listOf(GOOGLE_READ, GOOGLE_STARRED), MAX_ITEMS, null)
|
||||
},
|
||||
async { starredItems = getStarredItems(MAX_STARRED_ITEMS) },
|
||||
async { unreadIds = getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS) },
|
||||
async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) }
|
||||
).awaitAll()
|
||||
|
||||
}
|
||||
} else {
|
||||
DataSourceResult().apply {
|
||||
listOf(
|
||||
async { setItemsReadState(syncData, writeToken) },
|
||||
async { setItemsStarState(syncData, writeToken) },
|
||||
).awaitAll()
|
||||
|
||||
listOf(
|
||||
async { folders = getFolders() },
|
||||
async { feeds = getFeeds() },
|
||||
async { items = getItems(null, MAX_ITEMS, syncData.lastModified) },
|
||||
async { unreadIds = getItemsIds(GOOGLE_READ, GOOGLE_READING_LIST, MAX_ITEMS) },
|
||||
async {
|
||||
readIds = getItemsIds(GOOGLE_UNREAD, GOOGLE_READING_LIST, MAX_ITEMS)
|
||||
},
|
||||
async { starredIds = getItemsIds(null, GOOGLE_STARRED, MAX_STARRED_ITEMS) }
|
||||
).awaitAll()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
suspend fun getFolders() = service.getFolders()
|
||||
|
||||
suspend fun getFeeds() = service.getFeeds()
|
||||
|
||||
suspend fun getItems(excludeTargets: List<String>?, max: Int, lastModified: Long?): List<Item> {
|
||||
return service.getItems(excludeTargets, max, lastModified)
|
||||
}
|
||||
|
||||
suspend fun getStarredItems(max: Int) = service.getStarredItems(max)
|
||||
|
||||
suspend fun getItemsIds(excludeTarget: String?, includeTarget: String, max: Int): List<String> {
|
||||
return service.getItemsIds(excludeTarget, includeTarget, max)
|
||||
}
|
||||
|
||||
private suspend fun setItemsReadState(read: Boolean, itemIds: List<String>, token: String) {
|
||||
return if (read) {
|
||||
service.setItemsState(token, GOOGLE_READ, null, itemIds)
|
||||
} else {
|
||||
service.setItemsState(token, null, GOOGLE_READ, itemIds)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setItemStarState(starred: Boolean, itemIds: List<String>, token: String) {
|
||||
return if (starred) {
|
||||
service.setItemsState(token, GOOGLE_STARRED, null, itemIds)
|
||||
} else {
|
||||
service.setItemsState(token, null, GOOGLE_STARRED, itemIds)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun createFeed(token: String, feedUrl: String) {
|
||||
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "subscribe")
|
||||
}
|
||||
|
||||
suspend fun deleteFeed(token: String, feedUrl: String) {
|
||||
service.createOrDeleteFeed(token, FEED_PREFIX + feedUrl, "unsubscribe")
|
||||
}
|
||||
|
||||
suspend fun updateFeed(token: String, feedUrl: String, title: String, folderId: String) {
|
||||
service.updateFeed(token, FEED_PREFIX + feedUrl, title, folderId, "edit")
|
||||
}
|
||||
|
||||
suspend fun createFolder(token: String, tagName: String) {
|
||||
service.createFolder(token, "$FOLDER_PREFIX$tagName")
|
||||
}
|
||||
|
||||
suspend fun updateFolder(token: String, folderId: String, name: String) {
|
||||
service.updateFolder(token, folderId, "$FOLDER_PREFIX$name")
|
||||
}
|
||||
|
||||
suspend fun deleteFolder(token: String, folderId: String) {
|
||||
service.deleteFolder(token, folderId)
|
||||
}
|
||||
|
||||
private suspend fun setItemsReadState(syncData: FreshRSSSyncData, token: String) {
|
||||
if (syncData.readIds.isNotEmpty()) {
|
||||
setItemsReadState(true, syncData.readIds, token)
|
||||
}
|
||||
|
||||
if (syncData.unreadIds.isNotEmpty()) {
|
||||
setItemsReadState(false, syncData.unreadIds, token)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun setItemsStarState(syncData: FreshRSSSyncData, token: String) {
|
||||
if (syncData.starredIds.isNotEmpty()) {
|
||||
setItemStarState(true, syncData.starredIds, token)
|
||||
}
|
||||
|
||||
if (syncData.unstarredIds.isNotEmpty()) {
|
||||
setItemStarState(false, syncData.unstarredIds, token)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_ITEMS = 2500
|
||||
private const val MAX_STARRED_ITEMS = 1000
|
||||
|
||||
const val GOOGLE_READ = "user/-/state/com.google/read"
|
||||
const val GOOGLE_UNREAD = "user/-/state/com.google/unread"
|
||||
const val GOOGLE_STARRED = "user/-/state/com.google/starred"
|
||||
const val GOOGLE_READING_LIST = "user/-/state/com.google/reading-list"
|
||||
|
||||
const val FEED_PREFIX = "feed/"
|
||||
const val FOLDER_PREFIX = "user/-/label/"
|
||||
}
|
||||
}
|
|
@ -4,65 +4,68 @@ import com.readrops.api.services.freshrss.adapters.FreshRSSUserInfo
|
|||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.Item
|
||||
import io.reactivex.Completable
|
||||
import io.reactivex.Single
|
||||
import okhttp3.RequestBody
|
||||
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 {
|
||||
|
||||
@POST("accounts/ClientLogin")
|
||||
fun login(@Body body: RequestBody?): Single<ResponseBody?>?
|
||||
suspend fun login(@Body body: RequestBody?): ResponseBody
|
||||
|
||||
@get:GET("reader/api/0/token")
|
||||
val writeToken: Single<ResponseBody>
|
||||
@GET("reader/api/0/token")
|
||||
suspend fun getWriteToken(): ResponseBody
|
||||
|
||||
@get:GET("reader/api/0/user-info")
|
||||
val userInfo: Single<FreshRSSUserInfo>
|
||||
@GET("reader/api/0/user-info")
|
||||
suspend fun userInfo(): FreshRSSUserInfo
|
||||
|
||||
@get:GET("reader/api/0/subscription/list?output=json")
|
||||
val feeds: Single<List<Feed>>
|
||||
@GET("reader/api/0/subscription/list?output=json")
|
||||
suspend fun getFeeds(): List<Feed>
|
||||
|
||||
@get:GET("reader/api/0/tag/list?output=json")
|
||||
val folders: Single<List<Folder>>
|
||||
@GET("reader/api/0/tag/list?output=json")
|
||||
suspend fun getFolders(): List<Folder>
|
||||
|
||||
@GET("reader/api/0/stream/contents/user/-/state/com.google/reading-list")
|
||||
fun getItems(@Query("xt") excludeTarget: List<String>?, @Query("n") max: Int,
|
||||
@Query("ot") lastModified: Long?): Single<List<Item>>
|
||||
suspend fun getItems(@Query("xt") excludeTarget: List<String>?, @Query("n") max: Int,
|
||||
@Query("ot") lastModified: Long?): List<Item>
|
||||
|
||||
@GET("reader/api/0/stream/contents/user/-/state/com.google/starred")
|
||||
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")
|
||||
fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?,
|
||||
@Query("n") max: Int): Single<List<String>>
|
||||
suspend fun getItemsIds(@Query("xt") excludeTarget: String?, @Query("s") includeTarget: String?,
|
||||
@Query("n") max: Int): List<String>
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/edit-tag")
|
||||
fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?,
|
||||
@Field("r") removeAction: String?, @Field("i") itemIds: List<String>): Completable
|
||||
suspend fun setItemsState(@Field("T") token: String, @Field("a") addAction: String?,
|
||||
@Field("r") removeAction: String?, @Field("i") itemIds: List<String>)
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/subscription/edit")
|
||||
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
|
||||
@POST("reader/api/0/subscription/edit")
|
||||
fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String,
|
||||
@Field("a") folderId: String, @Field("ac") action: String): Completable
|
||||
suspend fun updateFeed(@Field("T") token: String, @Field("s") feedUrl: String, @Field("t") title: String,
|
||||
@Field("a") folderId: String, @Field("ac") action: String)
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("reader/api/0/edit-tag")
|
||||
fun createFolder(@Field("T") token: String, @Field("a") tagName: String): Completable
|
||||
suspend fun createFolder(@Field("T") token: String, @Field("a") tagName: String)
|
||||
|
||||
@FormUrlEncoded
|
||||
@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
|
||||
@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 {
|
||||
const val END_POINT = "/api/greader.php/"
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
package com.readrops.api.services.freshrss
|
||||
|
||||
data class FreshRSSSyncData(
|
||||
var lastModified: Long = 0,
|
||||
var readItemsIds: List<String> = listOf(),
|
||||
var unreadItemsIds: List<String> = listOf(),
|
||||
var starredItemsIds: List<String> = listOf(),
|
||||
var unstarredItemsIds: List<String> = listOf(),
|
||||
var lastModified: Long = 0,
|
||||
var readIds: List<String> = listOf(),
|
||||
var unreadIds: List<String> = listOf(),
|
||||
var starredIds: List<String> = listOf(),
|
||||
var unstarredIds: List<String> = listOf(),
|
||||
)
|
|
@ -1,17 +1,15 @@
|
|||
package com.readrops.api.services.freshrss.adapters
|
||||
|
||||
import android.util.TimingLogger
|
||||
import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_READ
|
||||
import com.readrops.api.services.freshrss.FreshRSSDataSource.GOOGLE_STARRED
|
||||
import com.readrops.api.services.freshrss.FreshRSSDataSource.Companion.GOOGLE_READ
|
||||
import com.readrops.api.services.freshrss.FreshRSSDataSource.Companion.GOOGLE_STARRED
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||
import com.readrops.api.utils.extensions.nextNullableString
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.util.DateUtils
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonWriter
|
||||
import org.joda.time.DateTimeZone
|
||||
import org.joda.time.LocalDateTime
|
||||
|
||||
class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
||||
|
||||
|
@ -47,8 +45,7 @@ class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
|||
with(item) {
|
||||
when (reader.selectName(NAMES)) {
|
||||
0 -> remoteId = reader.nextNonEmptyString()
|
||||
1 -> pubDate = LocalDateTime(reader.nextLong() * 1000L,
|
||||
DateTimeZone.getDefault())
|
||||
1 -> pubDate = DateUtils.fromEpochSeconds(reader.nextLong())
|
||||
2 -> title = reader.nextNonEmptyString()
|
||||
3 -> content = getContent(reader)
|
||||
4 -> link = getLink(reader)
|
||||
|
@ -108,7 +105,6 @@ class FreshRSSItemsAdapter : JsonAdapter<List<Item>>() {
|
|||
when (reader.nextString()) {
|
||||
GOOGLE_READ -> item.isRead = true
|
||||
GOOGLE_STARRED -> item.isStarred = true
|
||||
else -> reader.skipValue()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
package com.readrops.api.services.nextcloudnews
|
||||
|
||||
import com.readrops.api.services.Credentials
|
||||
|
||||
class NextNewsCredentials(login: String?, password: String?, url: String):
|
||||
Credentials((login != null && password != null).let {
|
||||
okhttp3.Credentials.basic(login!!, password!!)
|
||||
}, url)
|
|
@ -1,300 +0,0 @@
|
|||
package com.readrops.api.services.nextcloudnews;
|
||||
|
||||
import android.content.res.Resources;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.readrops.api.services.SyncResult;
|
||||
import com.readrops.api.services.SyncType;
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextNewsUserAdapter;
|
||||
import com.readrops.api.utils.ApiUtils;
|
||||
import com.readrops.api.utils.exceptions.ConflictException;
|
||||
import com.readrops.api.utils.exceptions.UnknownFormatException;
|
||||
import com.readrops.api.utils.extensions.KonsumerExtensionsKt;
|
||||
import com.readrops.db.entities.Feed;
|
||||
import com.readrops.db.entities.Folder;
|
||||
import com.readrops.db.entities.Item;
|
||||
import com.readrops.db.entities.account.Account;
|
||||
import com.readrops.db.pojo.StarItem;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import retrofit2.Response;
|
||||
|
||||
public class NextNewsDataSource {
|
||||
|
||||
private static final String TAG = NextNewsDataSource.class.getSimpleName();
|
||||
|
||||
private static final int MAX_ITEMS = 5000;
|
||||
private static final int MAX_STARRED_ITEMS = 1000;
|
||||
|
||||
private NextNewsService api;
|
||||
|
||||
public NextNewsDataSource(NextNewsService api) {
|
||||
this.api = api;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public String login(OkHttpClient client, Account account) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(account.getUrl() + "/ocs/v1.php/cloud/users/" + account.getLogin())
|
||||
.addHeader("OCS-APIRequest", "true")
|
||||
.build();
|
||||
|
||||
okhttp3.Response response = client.newCall(request).execute();
|
||||
|
||||
String displayName = new NextNewsUserAdapter().fromXml(KonsumerExtensionsKt
|
||||
.instantiateKonsumer(response.body().byteStream()));
|
||||
response.body().close();
|
||||
|
||||
return displayName;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public List<Feed> createFeed(String url, int folderId) throws IOException, UnknownFormatException {
|
||||
Response<List<Feed>> response = api.createFeed(url, folderId).execute();
|
||||
|
||||
if (!response.isSuccessful()) {
|
||||
if (response.code() == ApiUtils.HTTP_UNPROCESSABLE)
|
||||
throw new UnknownFormatException();
|
||||
else
|
||||
return null;
|
||||
}
|
||||
|
||||
return response.body();
|
||||
}
|
||||
|
||||
public SyncResult sync(@NonNull SyncType syncType, @Nullable NextNewsSyncData data) throws IOException {
|
||||
SyncResult syncResult = new SyncResult();
|
||||
switch (syncType) {
|
||||
case INITIAL_SYNC:
|
||||
initialSync(syncResult);
|
||||
break;
|
||||
case CLASSIC_SYNC:
|
||||
if (data == null)
|
||||
throw new NullPointerException("NextNewsSyncData can't be null");
|
||||
|
||||
classicSync(syncResult, data);
|
||||
break;
|
||||
}
|
||||
|
||||
return syncResult;
|
||||
}
|
||||
|
||||
private void initialSync(SyncResult syncResult) throws IOException {
|
||||
getFeedsAndFolders(syncResult);
|
||||
|
||||
// unread items
|
||||
Response<List<Item>> itemsResponse = api.getItems(ItemQueryType.ALL.value, false, MAX_ITEMS).execute();
|
||||
List<Item> itemList = itemsResponse.body();
|
||||
|
||||
if (!itemsResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
|
||||
if (itemList != null)
|
||||
syncResult.setItems(itemList);
|
||||
|
||||
// starred items
|
||||
Response<List<Item>> starredItemsResponse = api.getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS).execute();
|
||||
List<Item> starredItems = starredItemsResponse.body();
|
||||
|
||||
if (!itemsResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
|
||||
if (itemList != null)
|
||||
syncResult.setStarredItems(starredItems);
|
||||
}
|
||||
|
||||
private void classicSync(SyncResult syncResult, NextNewsSyncData data) throws IOException {
|
||||
putModifiedItems(data, syncResult);
|
||||
getFeedsAndFolders(syncResult);
|
||||
|
||||
Response<List<Item>> itemsResponse = api.getNewItems(data.getLastModified(), ItemQueryType.ALL.value).execute();
|
||||
List<Item> itemList = itemsResponse.body();
|
||||
|
||||
if (!itemsResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
|
||||
if (itemList != null)
|
||||
syncResult.setItems(itemList);
|
||||
}
|
||||
|
||||
private void getFeedsAndFolders(SyncResult syncResult) throws IOException {
|
||||
Response<List<Feed>> feedResponse = api.getFeeds().execute();
|
||||
List<Feed> feedList = feedResponse.body();
|
||||
|
||||
if (!feedResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
|
||||
Response<List<Folder>> folderResponse = api.getFolders().execute();
|
||||
List<Folder> folderList = folderResponse.body();
|
||||
|
||||
if (!folderResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
|
||||
if (folderList != null)
|
||||
syncResult.setFolders(folderList);
|
||||
|
||||
if (feedList != null)
|
||||
syncResult.setFeeds(feedList);
|
||||
|
||||
}
|
||||
|
||||
private void putModifiedItems(NextNewsSyncData data, SyncResult syncResult) throws IOException {
|
||||
setReadState(data.getReadItems(), syncResult, StateType.READ);
|
||||
setReadState(data.getUnreadItems(), syncResult, StateType.UNREAD);
|
||||
|
||||
setStarState(data.getStarredItems(), syncResult, StateType.STAR);
|
||||
setStarState(data.getUnstarredItems(), syncResult, StateType.UNSTAR);
|
||||
}
|
||||
|
||||
public List<Folder> createFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException {
|
||||
Map<String, String> folderNameMap = new HashMap<>();
|
||||
folderNameMap.put("name", folder.getName());
|
||||
|
||||
Response<List<Folder>> foldersResponse = api.createFolder(folderNameMap).execute();
|
||||
|
||||
if (foldersResponse.isSuccessful())
|
||||
return foldersResponse.body();
|
||||
else if (foldersResponse.code() == ApiUtils.HTTP_UNPROCESSABLE)
|
||||
throw new UnknownFormatException();
|
||||
else if (foldersResponse.code() == ApiUtils.HTTP_CONFLICT)
|
||||
throw new ConflictException();
|
||||
else
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
public boolean deleteFolder(Folder folder) throws IOException {
|
||||
Response response = api.deleteFolder(Integer.parseInt(folder.getRemoteId())).execute();
|
||||
|
||||
if (response.isSuccessful())
|
||||
return true;
|
||||
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
|
||||
throw new Resources.NotFoundException();
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean renameFolder(Folder folder) throws IOException, UnknownFormatException, ConflictException {
|
||||
Map<String, String> folderNameMap = new HashMap<>();
|
||||
folderNameMap.put("name", folder.getName());
|
||||
|
||||
Response response = api.renameFolder(Integer.parseInt(folder.getRemoteId()), folderNameMap).execute();
|
||||
|
||||
if (response.isSuccessful())
|
||||
return true;
|
||||
else {
|
||||
switch (response.code()) {
|
||||
case ApiUtils.HTTP_NOT_FOUND:
|
||||
throw new Resources.NotFoundException();
|
||||
case ApiUtils.HTTP_UNPROCESSABLE:
|
||||
throw new UnknownFormatException();
|
||||
case ApiUtils.HTTP_CONFLICT:
|
||||
throw new ConflictException();
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public boolean deleteFeed(int feedId) throws IOException {
|
||||
Response response = api.deleteFeed(feedId).execute();
|
||||
|
||||
if (response.isSuccessful())
|
||||
return true;
|
||||
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
|
||||
throw new Resources.NotFoundException();
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean changeFeedFolder(Feed feed) throws IOException {
|
||||
Map<String, Integer> folderIdMap = new HashMap<>();
|
||||
folderIdMap.put("folderId", Integer.parseInt(feed.getRemoteFolderId()));
|
||||
|
||||
Response response = api.changeFeedFolder(Integer.parseInt(feed.getRemoteId()), folderIdMap).execute();
|
||||
|
||||
if (response.isSuccessful())
|
||||
return true;
|
||||
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
|
||||
throw new Resources.NotFoundException();
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean renameFeed(Feed feed) throws IOException {
|
||||
Map<String, String> feedTitleMap = new HashMap<>();
|
||||
feedTitleMap.put("feedTitle", feed.getName());
|
||||
|
||||
Response response = api.renameFeed(Integer.parseInt(feed.getRemoteId()), feedTitleMap).execute();
|
||||
|
||||
if (response.isSuccessful())
|
||||
return true;
|
||||
else if (response.code() == ApiUtils.HTTP_NOT_FOUND)
|
||||
throw new Resources.NotFoundException();
|
||||
else
|
||||
return false;
|
||||
}
|
||||
|
||||
private void setReadState(List<String> items, SyncResult syncResult, StateType stateType) throws IOException {
|
||||
if (!items.isEmpty()) {
|
||||
Map<String, List<String>> itemIdsMap = new HashMap<>();
|
||||
itemIdsMap.put("items", items);
|
||||
|
||||
Response readItemsResponse = api.setReadState(stateType.name().toLowerCase(),
|
||||
itemIdsMap).execute();
|
||||
|
||||
if (!readItemsResponse.isSuccessful())
|
||||
syncResult.setError(true);
|
||||
}
|
||||
}
|
||||
|
||||
private void setStarState(List<StarItem> items, SyncResult syncResult, StateType stateType) throws IOException {
|
||||
if (!items.isEmpty()) {
|
||||
List<Map<String, String>> body = new ArrayList<>();
|
||||
for (StarItem item : items) {
|
||||
Map<String, String> itemBody = new HashMap<>();
|
||||
itemBody.put("feedId", item.getFeedRemoteId());
|
||||
itemBody.put("guidHash", item.getGuidHash());
|
||||
|
||||
body.add(itemBody);
|
||||
}
|
||||
|
||||
Response response = api.setStarState(stateType.name().toLowerCase(),
|
||||
Collections.singletonMap("items", body)).execute();
|
||||
if (!response.isSuccessful()) {
|
||||
syncResult.setError(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum StateType {
|
||||
READ,
|
||||
UNREAD,
|
||||
STAR,
|
||||
UNSTAR
|
||||
}
|
||||
|
||||
public enum ItemQueryType {
|
||||
ALL(3),
|
||||
STARRED(2);
|
||||
|
||||
private int value;
|
||||
|
||||
ItemQueryType(int value) {
|
||||
this.value = value;
|
||||
}
|
||||
|
||||
public int getValue() {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
package com.readrops.api.services.nextcloudnews
|
||||
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.Item
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Call
|
||||
import retrofit2.http.*
|
||||
|
||||
interface NextNewsService {
|
||||
|
||||
@GET("/ocs/v1.php/cloud/users/{userId}")
|
||||
@Headers("OCS-APIRequest: true")
|
||||
fun getUser(@Path("userId") userId: String): Call<ResponseBody>
|
||||
|
||||
@get:GET("folders")
|
||||
val folders: Call<List<Folder>>
|
||||
|
||||
@get:GET("feeds")
|
||||
val feeds: Call<List<Feed>>
|
||||
|
||||
@GET("items")
|
||||
fun getItems(@Query("type") type: Int, @Query("getRead") read: Boolean, @Query("batchSize") batchSize: Int): Call<List<Item>>
|
||||
|
||||
@GET("items/updated")
|
||||
fun getNewItems(@Query("lastModified") lastModified: Long, @Query("type") type: Int): Call<List<Item>>
|
||||
|
||||
@PUT("items/{stateType}/multiple")
|
||||
fun setReadState(@Path("stateType") stateType: String, @Body itemIdsMap: Map<String, List<String>>): Call<ResponseBody>
|
||||
|
||||
@PUT("items/{starType}/multiple")
|
||||
fun setStarState(@Path("starType") starType: String?, @Body body: Map<String?, List<Map<String, String>>>): Call<ResponseBody>
|
||||
|
||||
@POST("feeds")
|
||||
fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int): Call<List<Feed>>
|
||||
|
||||
@DELETE("feeds/{feedId}")
|
||||
fun deleteFeed(@Path("feedId") feedId: Int): Call<Void?>?
|
||||
|
||||
@PUT("feeds/{feedId}/move")
|
||||
fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map<String, Int>): Call<ResponseBody>
|
||||
|
||||
@PUT("feeds/{feedId}/rename")
|
||||
fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map<String, String>): Call<ResponseBody>
|
||||
|
||||
@POST("folders")
|
||||
fun createFolder(@Body folderName: Map<String, String>): Call<List<Folder>>
|
||||
|
||||
@DELETE("folders/{folderId}")
|
||||
fun deleteFolder(@Path("folderId") folderId: Int): Call<ResponseBody>
|
||||
|
||||
@PUT("folders/{folderId}")
|
||||
fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map<String, String>): Call<ResponseBody>
|
||||
|
||||
companion object {
|
||||
const val END_POINT = "/index.php/apps/news/api/v1-2/"
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
package com.readrops.api.services.nextcloudnews
|
||||
|
||||
import com.readrops.db.pojo.StarItem
|
||||
|
||||
data class NextNewsSyncData(
|
||||
var lastModified: Long = 0,
|
||||
var unreadItems: List<String> = listOf(),
|
||||
var readItems: List<String> = listOf(),
|
||||
var starredItems: List<StarItem> = listOf(),
|
||||
var unstarredItems: List<StarItem> = listOf(),
|
||||
)
|
|
@ -0,0 +1,8 @@
|
|||
package com.readrops.api.services.nextcloudnews
|
||||
|
||||
import com.readrops.api.services.Credentials
|
||||
|
||||
class NextcloudNewsCredentials(login: String?, password: String?, url: String):
|
||||
Credentials(if (login != null && password != null) {
|
||||
okhttp3.Credentials.basic(login, password)
|
||||
} else null, url)
|
|
@ -0,0 +1,153 @@
|
|||
package com.readrops.api.services.nextcloudnews
|
||||
|
||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||
import com.readrops.api.services.DataSourceResult
|
||||
import com.readrops.api.services.SyncType
|
||||
import com.readrops.api.services.nextcloudnews.adapters.NextcloudNewsUserAdapter
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.entities.account.Account
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
|
||||
class NextcloudNewsDataSource(private val service: NextcloudNewsService) {
|
||||
|
||||
suspend fun login(client: OkHttpClient, account: Account): String {
|
||||
val request = Request.Builder()
|
||||
.url(account.url + "/ocs/v1.php/cloud/users/" + account.login)
|
||||
.addHeader("OCS-APIRequest", "true")
|
||||
.build()
|
||||
|
||||
val response = client.newCall(request)
|
||||
.execute()
|
||||
|
||||
val displayName = NextcloudNewsUserAdapter().fromXml(response.body!!.byteStream().konsumeXml())
|
||||
response.close()
|
||||
|
||||
return displayName
|
||||
}
|
||||
|
||||
suspend fun synchronize(syncType: SyncType, syncData: NextcloudNewsSyncData): DataSourceResult =
|
||||
with(CoroutineScope(Dispatchers.IO)) {
|
||||
return if (syncType == SyncType.INITIAL_SYNC) {
|
||||
DataSourceResult().apply {
|
||||
listOf(
|
||||
async { folders = getFolders() },
|
||||
async { feeds = getFeeds() },
|
||||
async { items = getItems(ItemQueryType.ALL.value, false, MAX_ITEMS) },
|
||||
async {
|
||||
starredItems =
|
||||
getItems(ItemQueryType.STARRED.value, true, MAX_STARRED_ITEMS)
|
||||
}
|
||||
).awaitAll()
|
||||
}
|
||||
} else {
|
||||
listOf(
|
||||
async { setItemsReadState(syncData) },
|
||||
async { setItemsStarState(syncData) },
|
||||
).awaitAll()
|
||||
|
||||
DataSourceResult().apply {
|
||||
listOf(
|
||||
async { folders = getFolders() },
|
||||
async { feeds = getFeeds() },
|
||||
async { items = getNewItems(syncData.lastModified, ItemQueryType.ALL) }
|
||||
).awaitAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getFolders() = service.getFolders()
|
||||
|
||||
suspend fun getFeeds() = service.getFeeds()
|
||||
|
||||
suspend fun getItems(type: Int, read: Boolean, batchSize: Int): List<Item> {
|
||||
return service.getItems(type, read, batchSize)
|
||||
}
|
||||
|
||||
suspend fun getNewItems(lastModified: Long, itemQueryType: ItemQueryType): List<Item> {
|
||||
return service.getNewItems(lastModified, itemQueryType.value)
|
||||
}
|
||||
|
||||
suspend fun createFeed(url: String, folderId: Int?): List<Feed> {
|
||||
return service.createFeed(url, folderId)
|
||||
}
|
||||
|
||||
suspend fun changeFeedFolder(newFolderId: Int?, feedId: Int) {
|
||||
service.changeFeedFolder(feedId, mapOf("folderId" to newFolderId))
|
||||
}
|
||||
|
||||
suspend fun renameFeed(name: String, feedId: Int) {
|
||||
service.renameFeed(feedId, mapOf("feedTitle" to name))
|
||||
}
|
||||
|
||||
suspend fun deleteFeed(feedId: Int) {
|
||||
service.deleteFeed(feedId)
|
||||
}
|
||||
|
||||
suspend fun createFolder(name: String): List<Folder> {
|
||||
return service.createFolder(mapOf("name" to name))
|
||||
}
|
||||
|
||||
suspend fun renameFolder(name: String, folderId: Int) {
|
||||
service.renameFolder(folderId, mapOf("name" to name))
|
||||
}
|
||||
|
||||
suspend fun deleteFolder(folderId: Int) {
|
||||
service.deleteFolder(folderId)
|
||||
}
|
||||
|
||||
suspend fun setItemsReadState(syncData: NextcloudNewsSyncData) = with(syncData) {
|
||||
if (unreadIds.isNotEmpty()) {
|
||||
service.setReadState(
|
||||
StateType.UNREAD.name.lowercase(),
|
||||
mapOf("itemIds" to unreadIds)
|
||||
)
|
||||
}
|
||||
|
||||
if (readIds.isNotEmpty()) {
|
||||
service.setReadState(
|
||||
StateType.READ.name.lowercase(),
|
||||
mapOf("itemIds" to readIds)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setItemsStarState(syncData: NextcloudNewsSyncData) = with(syncData) {
|
||||
if (starredIds.isNotEmpty()) {
|
||||
service.setStarState(
|
||||
StateType.STAR.name.lowercase(),
|
||||
mapOf("itemIds" to starredIds)
|
||||
)
|
||||
}
|
||||
|
||||
if (unstarredIds.isNotEmpty()) {
|
||||
service.setStarState(
|
||||
StateType.UNSTAR.name.lowercase(),
|
||||
mapOf("itemIds" to unstarredIds)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum class ItemQueryType(val value: Int) {
|
||||
ALL(3),
|
||||
STARRED(2)
|
||||
}
|
||||
|
||||
enum class StateType {
|
||||
READ,
|
||||
UNREAD,
|
||||
STAR,
|
||||
UNSTAR
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val MAX_ITEMS = 5000
|
||||
private const val MAX_STARRED_ITEMS = 1000
|
||||
}
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
package com.readrops.api.services.nextcloudnews
|
||||
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.Item
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.DELETE
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
interface NextcloudNewsService {
|
||||
|
||||
@GET("folders")
|
||||
suspend fun getFolders(): List<Folder>
|
||||
|
||||
@GET("feeds")
|
||||
suspend fun getFeeds(): List<Feed>
|
||||
|
||||
@GET("items")
|
||||
suspend fun getItems(
|
||||
@Query("type") type: Int,
|
||||
@Query("getRead") read: Boolean,
|
||||
@Query("batchSize") batchSize: Int
|
||||
): List<Item>
|
||||
|
||||
@GET("items/updated")
|
||||
suspend fun getNewItems(
|
||||
@Query("lastModified") lastModified: Long,
|
||||
@Query("type") type: Int
|
||||
): List<Item>
|
||||
|
||||
@POST("items/{stateType}/multiple")
|
||||
@JvmSuppressWildcards
|
||||
suspend fun setReadState(
|
||||
@Path("stateType") stateType: String,
|
||||
@Body itemIdsMap: Map<String, List<Int>>
|
||||
)
|
||||
|
||||
@POST("items/{starType}/multiple")
|
||||
@JvmSuppressWildcards
|
||||
suspend fun setStarState(
|
||||
@Path("starType") starType: String?,
|
||||
@Body body: Map<String, List<Int>>
|
||||
)
|
||||
|
||||
@POST("feeds")
|
||||
suspend fun createFeed(@Query("url") url: String, @Query("folderId") folderId: Int?): List<Feed>
|
||||
|
||||
@DELETE("feeds/{feedId}")
|
||||
suspend fun deleteFeed(@Path("feedId") feedId: Int)
|
||||
|
||||
@POST("feeds/{feedId}/move")
|
||||
suspend fun changeFeedFolder(@Path("feedId") feedId: Int, @Body folderIdMap: Map<String, Int?>)
|
||||
|
||||
@POST("feeds/{feedId}/rename")
|
||||
suspend fun renameFeed(@Path("feedId") feedId: Int, @Body feedTitleMap: Map<String, String>)
|
||||
|
||||
@POST("folders")
|
||||
suspend fun createFolder(@Body folderName: Map<String, String>): List<Folder>
|
||||
|
||||
@DELETE("folders/{folderId}")
|
||||
suspend fun deleteFolder(@Path("folderId") folderId: Int)
|
||||
|
||||
@PUT("folders/{folderId}")
|
||||
suspend fun renameFolder(@Path("folderId") folderId: Int, @Body folderName: Map<String, String>)
|
||||
|
||||
companion object {
|
||||
const val END_POINT = "/index.php/apps/news/api/v1-3/"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.readrops.api.services.nextcloudnews
|
||||
|
||||
data class NextcloudNewsSyncData(
|
||||
val lastModified: Long = 0,
|
||||
val readIds: List<Int> = listOf(),
|
||||
val unreadIds: List<Int> = listOf(),
|
||||
val starredIds: List<Int> = listOf(),
|
||||
val unstarredIds: List<Int> = listOf(),
|
||||
)
|
|
@ -11,7 +11,7 @@ import com.squareup.moshi.JsonReader
|
|||
import com.squareup.moshi.ToJson
|
||||
import java.net.URI
|
||||
|
||||
class NextNewsFeedsAdapter {
|
||||
class NextcloudNewsFeedsAdapter {
|
||||
|
||||
@ToJson
|
||||
fun toJson(feeds: List<Feed>): String = ""
|
|
@ -8,7 +8,7 @@ import com.squareup.moshi.FromJson
|
|||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.ToJson
|
||||
|
||||
class NextNewsFoldersAdapter {
|
||||
class NextcloudNewsFoldersAdapter {
|
||||
|
||||
@ToJson
|
||||
fun toJson(folders: List<Folder>): String = ""
|
|
@ -1,18 +1,17 @@
|
|||
package com.readrops.api.services.nextcloudnews.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nextNonEmptyString
|
||||
import com.readrops.api.utils.extensions.nextNullableString
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.util.DateUtils
|
||||
import com.squareup.moshi.JsonAdapter
|
||||
import com.squareup.moshi.JsonReader
|
||||
import com.squareup.moshi.JsonWriter
|
||||
import 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>?) {
|
||||
// no need of this
|
||||
|
@ -42,14 +41,13 @@ class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
|
|||
1 -> link = reader.nextNullableString()
|
||||
2 -> title = reader.nextNonEmptyString()
|
||||
3 -> author = reader.nextNullableString()
|
||||
4 -> pubDate = LocalDateTime(reader.nextLong() * 1000L, DateTimeZone.getDefault())
|
||||
4 -> pubDate = DateUtils.fromEpochSeconds(reader.nextLong())
|
||||
5 -> content = reader.nextNullableString()
|
||||
6 -> enclosureMime = reader.nextNullableString()
|
||||
7 -> enclosureLink = reader.nextNullableString()
|
||||
8 -> feedRemoteId = reader.nextInt().toString()
|
||||
9 -> isRead = !reader.nextBoolean() // the negation is important here
|
||||
10 -> isStarred = reader.nextBoolean()
|
||||
11 -> guid = reader.nextNullableString()
|
||||
else -> reader.skipValue()
|
||||
}
|
||||
}
|
||||
|
@ -73,6 +71,6 @@ class NextNewsItemsAdapter : JsonAdapter<List<Item>>() {
|
|||
|
||||
companion object {
|
||||
val NAMES: JsonReader.Options = JsonReader.Options.of("id", "url", "title", "author",
|
||||
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred", "guidHash")
|
||||
"pubDate", "body", "enclosureMime", "enclosureLink", "feedId", "unread", "starred")
|
||||
}
|
||||
}
|
|
@ -6,7 +6,7 @@ import com.readrops.api.localfeed.XmlAdapter
|
|||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.api.utils.extensions.nonNullText
|
||||
|
||||
class NextNewsUserAdapter : XmlAdapter<String> {
|
||||
class NextcloudNewsUserAdapter : XmlAdapter<String> {
|
||||
|
||||
override fun fromXml(konsumer: Konsumer): String {
|
||||
var displayName: String? = null
|
|
@ -18,6 +18,8 @@ object ApiUtils {
|
|||
const val HTTP_NOT_FOUND = 404
|
||||
const val HTTP_CONFLICT = 409
|
||||
|
||||
val OPML_MIMETYPES = listOf("application/xml", "text/xml", "text/x-opml")
|
||||
|
||||
private const val RSS_CONTENT_TYPE_REGEX = "([^;]+)"
|
||||
|
||||
fun isMimeImage(type: String): Boolean =
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
package com.readrops.api.utils
|
||||
|
||||
import org.joda.time.LocalDateTime
|
||||
import org.joda.time.format.DateTimeFormat
|
||||
import org.joda.time.format.DateTimeFormatterBuilder
|
||||
import java.util.*
|
||||
|
||||
object DateUtils {
|
||||
|
||||
private val TAG = DateUtils::class.java.simpleName
|
||||
|
||||
/**
|
||||
* Base of common RSS 2 date formats.
|
||||
* Examples :
|
||||
* Fri, 04 Jan 2019 22:21:46 GMT
|
||||
* Fri, 04 Jan 2019 22:21:46 +0000
|
||||
*/
|
||||
private const val RSS_2_BASE_PATTERN = "EEE, dd MMM yyyy HH:mm:ss"
|
||||
|
||||
private const val GMT_PATTERN = "ZZZ"
|
||||
|
||||
private const val OFFSET_PATTERN = "Z"
|
||||
|
||||
private const val ISO_PATTERN = ".SSSZZ"
|
||||
|
||||
private const val EDT_PATTERN = "zzz"
|
||||
|
||||
/**
|
||||
* Date pattern for format : 2019-01-04T22:21:46+00:00
|
||||
*/
|
||||
private const val ATOM_JSON_DATE_FORMAT = "yyyy-MM-dd'T'HH:mm:ss"
|
||||
|
||||
@JvmStatic
|
||||
fun parse(value: String?): LocalDateTime? = if (value == null) {
|
||||
null
|
||||
} else try {
|
||||
val formatter = DateTimeFormatterBuilder()
|
||||
.appendOptional(DateTimeFormat.forPattern("$RSS_2_BASE_PATTERN ").parser) // with timezone
|
||||
.appendOptional(DateTimeFormat.forPattern(RSS_2_BASE_PATTERN).parser) // no timezone, important order here
|
||||
.appendOptional(DateTimeFormat.forPattern(ATOM_JSON_DATE_FORMAT).parser)
|
||||
.appendOptional(DateTimeFormat.forPattern(GMT_PATTERN).parser)
|
||||
.appendOptional(DateTimeFormat.forPattern(OFFSET_PATTERN).parser)
|
||||
.appendOptional(DateTimeFormat.forPattern(ISO_PATTERN).parser)
|
||||
.appendOptional(DateTimeFormat.forPattern(EDT_PATTERN).parser)
|
||||
.toFormatter()
|
||||
.withLocale(Locale.ENGLISH)
|
||||
.withOffsetParsed()
|
||||
|
||||
formatter.parseLocalDateTime(value)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun formattedDateByLocal(dateTime: LocalDateTime): String {
|
||||
return DateTimeFormat.mediumDate()
|
||||
.withLocale(Locale.getDefault())
|
||||
.print(dateTime)
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun formattedDateTimeByLocal(dateTime: LocalDateTime): String {
|
||||
return DateTimeFormat.forPattern("dd MMM yyyy · HH:mm")
|
||||
.withLocale(Locale.getDefault())
|
||||
.print(dateTime)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
package com.readrops.api.utils
|
||||
|
||||
import com.readrops.api.utils.exceptions.HttpException
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class ErrorInterceptor : Interceptor {
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val response = chain.proceed(request)
|
||||
|
||||
// TODO cover all cases
|
||||
if (!response.isSuccessful && response.code != 304) {
|
||||
throw HttpException(response)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
}
|
|
@ -0,0 +1,88 @@
|
|||
package com.readrops.api.utils
|
||||
|
||||
import android.nfc.FormatException
|
||||
import com.readrops.api.localfeed.LocalRSSHelper
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jsoup.Jsoup
|
||||
import org.jsoup.nodes.Document
|
||||
|
||||
data class ParsingResult(
|
||||
val url: String,
|
||||
val label: String?,
|
||||
)
|
||||
|
||||
object HtmlParser {
|
||||
|
||||
suspend fun getFaviconLink(url: String, client: OkHttpClient): String? {
|
||||
val document = getHTMLHeadFromUrl(url, client)
|
||||
val elements = document.select("link")
|
||||
|
||||
for (element in elements) {
|
||||
if (element.attributes()["rel"].lowercase().contains("icon")) {
|
||||
return element.absUrl("href")
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun getFeedLink(url: String, client: OkHttpClient): List<ParsingResult> {
|
||||
val results = mutableListOf<ParsingResult>()
|
||||
|
||||
val document = getHTMLHeadFromUrl(url, client)
|
||||
val elements = document.select("link")
|
||||
|
||||
for (element in elements) {
|
||||
val type = element.attributes()["type"]
|
||||
|
||||
if (LocalRSSHelper.isRSSType(type)) {
|
||||
results += ParsingResult(
|
||||
url = element.absUrl("href"),
|
||||
label = element.attributes()["title"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
private fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document {
|
||||
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
|
||||
if (response.header(ApiUtils.CONTENT_TYPE_HEADER)!!.contains(ApiUtils.HTML_CONTENT_TYPE)
|
||||
) {
|
||||
val body = response.body!!.source()
|
||||
|
||||
val stringBuilder = StringBuilder()
|
||||
var collectionStarted = false
|
||||
|
||||
while (!body.exhausted()) {
|
||||
val currentLine = body.readUtf8LineStrict()
|
||||
|
||||
when {
|
||||
currentLine.contains("<head>") -> {
|
||||
stringBuilder.append(currentLine)
|
||||
collectionStarted = true
|
||||
}
|
||||
currentLine.contains("</head>") -> {
|
||||
stringBuilder.append(currentLine)
|
||||
break
|
||||
}
|
||||
collectionStarted -> {
|
||||
stringBuilder.append(currentLine)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!stringBuilder.contains("<head>") || !stringBuilder.contains("</head>"))
|
||||
throw FormatException("Failed to get HTML head")
|
||||
|
||||
body.close()
|
||||
return Jsoup.parse(stringBuilder.toString(), url)
|
||||
} else {
|
||||
throw FormatException("The response is not a html file")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
package com.readrops.api.utils.exceptions
|
||||
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
class HttpException(val response: Response) : IOException() {
|
||||
|
||||
val code: Int
|
||||
get() = response.code
|
||||
|
||||
override val message: String
|
||||
get() = "HTTP " + response.code + " " + response.message
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
package com.readrops.api
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import java.io.InputStream
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
fun MockWebServer.enqueueOK() {
|
||||
enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
)
|
||||
}
|
||||
|
||||
fun MockWebServer.enqueueStream(stream: InputStream) {
|
||||
enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
}
|
|
@ -5,6 +5,7 @@ import com.readrops.api.TestUtils
|
|||
import com.readrops.api.apiModule
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
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.UnknownFormatException
|
||||
import junit.framework.TestCase.*
|
||||
|
@ -149,7 +150,7 @@ class LocalRSSDataSourceTest : KoinTest {
|
|||
assertNull(pair)
|
||||
}
|
||||
|
||||
@Test(expected = NetworkErrorException::class)
|
||||
@Test(expected = HttpException::class)
|
||||
fun response404Test() {
|
||||
mockServer.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
|
||||
|
||||
|
|
|
@ -2,14 +2,13 @@ package com.readrops.api.localfeed.atom
|
|||
|
||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.util.DateUtils
|
||||
import junit.framework.TestCase
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.lang.Exception
|
||||
|
||||
class ATOMAdapterTest {
|
||||
|
||||
|
@ -37,7 +36,7 @@ class ATOMAdapterTest {
|
|||
assertEquals(pubDate, DateUtils.parse("2020-09-06T21:09:59Z"))
|
||||
assertEquals(author, "Shinokuni")
|
||||
assertEquals(description, "Summary")
|
||||
assertEquals(guid, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
|
||||
assertEquals(remoteId, "tag:github.com,2008:Grit::Commit/c15f093a1bc4211e85f8d1817c9073e307afe5ac")
|
||||
TestCase.assertNotNull(content)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,15 +1,14 @@
|
|||
package com.readrops.api.localfeed.json
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.util.DateUtils
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import junit.framework.TestCase
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertTrue
|
||||
import okio.Buffer
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
@ -40,7 +39,7 @@ class JSONFeedAdapterTest {
|
|||
|
||||
with(items[0]) {
|
||||
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(link, "http://flyingmeat.com/blog/archives/2017/9/acorn_and_10.13.html")
|
||||
assertEquals(pubDate, DateUtils.parse("2017-09-25T14:27:27-07:00"))
|
||||
|
|
|
@ -2,8 +2,8 @@ package com.readrops.api.localfeed.rss1
|
|||
|
||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.util.DateUtils
|
||||
import junit.framework.Assert.assertEquals
|
||||
import junit.framework.Assert.assertNotNull
|
||||
import junit.framework.TestCase
|
||||
|
@ -35,7 +35,7 @@ class RSS1AdapterTest {
|
|||
assertEquals(title, "Google Expands its Flutter Development Kit To Windows Apps")
|
||||
assertEquals(link!!.trim(), "https://developers.slashdot.org/story/20/09/23/1616231/google-expands-" +
|
||||
"its-flutter-development-kit-to-windows-apps?utm_source=rss1.0mainlinkanon&utm_medium=feed")
|
||||
assertEquals(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")
|
||||
assertEquals(pubDate, DateUtils.parse("2020-09-23T16:15:00+00:00"))
|
||||
assertEquals(author, "msmash")
|
||||
|
|
|
@ -2,8 +2,8 @@ package com.readrops.api.localfeed.rss2
|
|||
|
||||
import com.gitlab.mvysny.konsumexml.konsumeXml
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.utils.DateUtils
|
||||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.util.DateUtils
|
||||
import junit.framework.TestCase
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertTrue
|
||||
|
@ -36,7 +36,7 @@ class RSS2AdapterTest {
|
|||
assertEquals(pubDate, DateUtils.parse("Tue, 25 Aug 2020 17:15:49 +0000"))
|
||||
assertEquals(author, "Author 1")
|
||||
assertEquals(description, "<a href=\"https://news.ycombinator.com/item?id=24273602\">Comments</a>")
|
||||
assertEquals(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 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.pubDate, DateUtils.parse("2020-08-05T14:03:48Z"))
|
||||
assertEquals(item.content, "content:encoded")
|
||||
|
|
|
@ -4,8 +4,8 @@ import com.readrops.api.TestUtils
|
|||
import com.readrops.api.utils.exceptions.ParseException
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import io.reactivex.schedulers.Schedulers
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
@ -13,83 +13,85 @@ import java.io.FileOutputStream
|
|||
class OPMLParserTest {
|
||||
|
||||
@Test
|
||||
fun readOpmlTest() {
|
||||
fun readOpmlTest() = runTest {
|
||||
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)
|
||||
.observeOn(Schedulers.trampoline())
|
||||
.subscribeOn(Schedulers.trampoline())
|
||||
.subscribe { result -> foldersAndFeeds = result }
|
||||
|
||||
assertEquals(foldersAndFeeds?.size, 6)
|
||||
|
||||
assertEquals(foldersAndFeeds?.get(Folder(name = "Folder 1"))?.size, 2)
|
||||
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 1"))?.size, 4)
|
||||
assertEquals(foldersAndFeeds?.get(Folder(name = "Subfolder 2"))?.size, 1)
|
||||
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 1"))?.size, 2)
|
||||
assertEquals(foldersAndFeeds?.get(Folder(name = "Sub subfolder 2"))?.size, 0)
|
||||
assertEquals(foldersAndFeeds?.get(null)?.size, 2)
|
||||
assertEquals(foldersAndFeeds[Folder(name = "Folder 1")]?.size, 2)
|
||||
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 1")]?.size, 4)
|
||||
assertEquals(foldersAndFeeds[Folder(name = "Subfolder 2")]?.size, 1)
|
||||
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 1")]?.size, 2)
|
||||
assertEquals(foldersAndFeeds[Folder(name = "Sub subfolder 2")]?.size, 0)
|
||||
assertEquals(foldersAndFeeds[null]?.size, 2)
|
||||
|
||||
stream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun readLiteSubscriptionsTest() {
|
||||
fun readLiteSubscriptionsTest() = runTest {
|
||||
val stream = TestUtils.loadResource("opml/lite_subscriptions.opml")
|
||||
|
||||
var foldersAndFeeds: Map<Folder?, List<Feed>>? = null
|
||||
val foldersAndFeeds = OPMLParser.read(stream)
|
||||
|
||||
OPMLParser.read(stream)
|
||||
.subscribe { result -> foldersAndFeeds = result }
|
||||
|
||||
assertEquals(foldersAndFeeds?.values?.first()?.size, 2)
|
||||
assertEquals(foldersAndFeeds?.values?.first()?.first()?.url, "http://www.theverge.com/rss/index.xml")
|
||||
assertEquals(foldersAndFeeds?.values?.first()?.get(1)?.url, "https://techcrunch.com/feed/")
|
||||
assertEquals(foldersAndFeeds.values.first().size, 2)
|
||||
assertEquals(
|
||||
foldersAndFeeds.values.first().first().url,
|
||||
"http://www.theverge.com/rss/index.xml"
|
||||
)
|
||||
assertEquals(foldersAndFeeds.values.first()[1].url, "https://techcrunch.com/feed/")
|
||||
|
||||
stream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun opmlVersionTest() {
|
||||
@Test(expected = ParseException::class)
|
||||
fun opmlVersionTest() = runTest {
|
||||
val stream = TestUtils.loadResource("opml/wrong_version.opml")
|
||||
|
||||
OPMLParser.read(stream)
|
||||
.test()
|
||||
.assertError(ParseException::class.java)
|
||||
|
||||
stream.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writeOpmlTest() {
|
||||
fun writeOpmlTest() = runTest {
|
||||
val file = File("subscriptions.opml")
|
||||
val outputStream = FileOutputStream(file)
|
||||
|
||||
val foldersAndFeeds: Map<Folder?, List<Feed>> = HashMap<Folder?, List<Feed>>().apply {
|
||||
put(null, listOf(Feed(name = "Feed1", url = "https://feed1.com"),
|
||||
Feed(name = "Feed2", url = "https://feed2.com")))
|
||||
put(
|
||||
null, listOf(
|
||||
Feed(name = "Feed1", url = "https://feed1.com"),
|
||||
Feed(name = "Feed2", url = "https://feed2.com")
|
||||
)
|
||||
)
|
||||
put(Folder(name = "Folder1"), listOf())
|
||||
put(Folder(name = "Folder2"), listOf(Feed(name = "Feed3", url = "https://feed3.com"),
|
||||
Feed(name = "Feed4", url ="https://feed4.com")))
|
||||
put(
|
||||
Folder(name = "Folder2"), listOf(
|
||||
Feed(name = "Feed3", url = "https://feed3.com"),
|
||||
Feed(name = "Feed4", url = "https://feed4.com")
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
OPMLParser.write(foldersAndFeeds, outputStream)
|
||||
.subscribeOn(Schedulers.trampoline())
|
||||
.subscribe()
|
||||
|
||||
outputStream.flush()
|
||||
outputStream.close()
|
||||
|
||||
val inputStream = file.inputStream()
|
||||
var foldersAndFeeds2: Map<Folder?, List<Feed>>? = null
|
||||
OPMLParser.read(inputStream).subscribe { result -> foldersAndFeeds2 = result }
|
||||
val foldersAndFeeds2 = OPMLParser.read(inputStream)
|
||||
|
||||
assertEquals(foldersAndFeeds.size, foldersAndFeeds2?.size)
|
||||
assertEquals(foldersAndFeeds[Folder(name = "Folder1")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder1"))?.size)
|
||||
assertEquals(foldersAndFeeds[Folder(name = "Folder2")]?.size, foldersAndFeeds2?.get(Folder(name = "Folder2"))?.size)
|
||||
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2?.get(null)?.size)
|
||||
assertEquals(foldersAndFeeds.size, foldersAndFeeds2.size)
|
||||
assertEquals(
|
||||
foldersAndFeeds[Folder(name = "Folder1")]?.size,
|
||||
foldersAndFeeds2[Folder(name = "Folder1")]?.size
|
||||
)
|
||||
assertEquals(
|
||||
foldersAndFeeds[Folder(name = "Folder2")]?.size,
|
||||
foldersAndFeeds2[Folder(name = "Folder2")]?.size
|
||||
)
|
||||
assertEquals(foldersAndFeeds[null]?.size, foldersAndFeeds2[null]?.size)
|
||||
|
||||
inputStream.close()
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
package com.readrops.api.services
|
||||
|
||||
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 kotlin.test.assertEquals
|
||||
|
||||
|
@ -17,7 +17,7 @@ class CredentialsTest {
|
|||
|
||||
@Test
|
||||
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.url, "https://freshrss.org")
|
||||
|
|
|
@ -0,0 +1,260 @@
|
|||
package com.readrops.api.services.freshrss
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.apiModule
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.KoinTestRule
|
||||
import org.koin.test.get
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URLEncoder
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FreshRSSDataSourceTest : KoinTest {
|
||||
|
||||
private lateinit var freshRSSDataSource: FreshRSSDataSource
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
@get:Rule
|
||||
val koinTestRule = KoinTestRule.create {
|
||||
modules(apiModule, module {
|
||||
single {
|
||||
Retrofit.Builder()
|
||||
.baseUrl("http://localhost:8080/")
|
||||
.client(get())
|
||||
.addConverterFactory(MoshiConverterFactory.create(get(named("freshrssMoshi"))))
|
||||
.build()
|
||||
.create(FreshRSSService::class.java)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
mockServer.start(8080)
|
||||
freshRSSDataSource = FreshRSSDataSource(get())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginTest() {
|
||||
runBlocking {
|
||||
val responseBody = TestUtils.loadResource("services/freshrss/login_response_body")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(responseBody)))
|
||||
|
||||
val authString = freshRSSDataSource.login("Login", "Password")
|
||||
assertEquals("login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a", authString)
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
val requestBody = request.body.readUtf8()
|
||||
|
||||
assertTrue {
|
||||
requestBody.contains("name=\"Email\"") && requestBody.contains("Login")
|
||||
}
|
||||
|
||||
assertTrue {
|
||||
requestBody.contains("name=\"Passwd\"") && requestBody.contains("Password")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun writeTokenTest() = runBlocking {
|
||||
val responseBody = TestUtils.loadResource("services/freshrss/writetoken_response_body")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(responseBody)))
|
||||
|
||||
val writeToken = freshRSSDataSource.getWriteToken()
|
||||
|
||||
assertEquals("PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg", writeToken)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun userInfoTest() = runBlocking {
|
||||
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foldersTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/folders.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val folders = freshRSSDataSource.getFolders()
|
||||
assertTrue { folders.size == 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feedsTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/feeds.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val feeds = freshRSSDataSource.getFeeds()
|
||||
assertTrue { feeds.size == 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun itemsTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val items = freshRSSDataSource.getItems(listOf(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_STARRED), 100, 21343321321321)
|
||||
assertTrue { items.size == 2 }
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.requestUrl!!) {
|
||||
assertEquals(listOf(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_STARRED), queryParameterValues("xt"))
|
||||
assertEquals("100", queryParameter("n"))
|
||||
assertEquals("21343321321321", queryParameter("ot"))
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun starredItemsTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/items.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val items = freshRSSDataSource.getStarredItems(100)
|
||||
assertTrue { items.size == 2 }
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertEquals("100", request.requestUrl!!.queryParameter("n"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getItemsIdsTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("services/freshrss/adapters/items_starred_ids.json")
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.setBody(Buffer().readFrom(stream)))
|
||||
|
||||
val ids = freshRSSDataSource.getItemsIds(FreshRSSDataSource.GOOGLE_READ, FreshRSSDataSource.GOOGLE_READING_LIST, 100)
|
||||
assertTrue { ids.size == 5 }
|
||||
|
||||
val request = mockServer.takeRequest()
|
||||
with(request.requestUrl!!) {
|
||||
assertEquals(FreshRSSDataSource.GOOGLE_READ, queryParameter("xt"))
|
||||
assertEquals(FreshRSSDataSource.GOOGLE_READING_LIST, queryParameter("s"))
|
||||
assertEquals("100", queryParameter("n"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createFeedTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.createFeed("token", "https://feed.url")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=${URLEncoder.encode("${FreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
|
||||
assertTrue { contains("ac=subscribe") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteFeedTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.deleteFeed("token", "https://feed.url")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=${URLEncoder.encode("${FreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
|
||||
assertTrue { contains("ac=unsubscribe") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateFeedTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.updateFeed("token", "https://feed.url", "title", "folderId")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=${URLEncoder.encode("${FreshRSSDataSource.FEED_PREFIX}https://feed.url", "UTF-8")}") }
|
||||
assertTrue { contains("t=title") }
|
||||
assertTrue { contains("a=folderId") }
|
||||
assertTrue { contains("ac=edit") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createFolderTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.createFolder("token", "folder")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("a=${URLEncoder.encode("${FreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateFolderTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.updateFolder("token", "folderId", "folder")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=folderId") }
|
||||
assertTrue { contains("dest=${URLEncoder.encode("${FreshRSSDataSource.FOLDER_PREFIX}folder", "UTF-8")}") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteFolderTest() = runBlocking {
|
||||
mockServer.enqueue(MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK))
|
||||
|
||||
freshRSSDataSource.deleteFolder("token", "folderId")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
with(request.body.readUtf8()) {
|
||||
assertTrue { contains("T=token") }
|
||||
assertTrue { contains("s=folderId") }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,12 +2,12 @@ package com.readrops.api.services.freshrss.adapters
|
|||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.util.DateUtils
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import junit.framework.TestCase.assertNotNull
|
||||
import okio.Buffer
|
||||
import org.joda.time.LocalDateTime
|
||||
import org.junit.Test
|
||||
|
||||
class FreshRSSItemsAdapterTest {
|
||||
|
@ -29,7 +29,7 @@ class FreshRSSItemsAdapterTest {
|
|||
assertNotNull(content)
|
||||
assertEquals(link, "http://feedproxy.google.com/~r/d0od/~3/4Zk-fncSuek/adwaita-borderless-theme-in-development-gnome-41")
|
||||
assertEquals(author, "Joey Sneddon")
|
||||
assertEquals(pubDate, LocalDateTime(1625234040 * 1000L))
|
||||
assertEquals(pubDate, DateUtils.fromEpochSeconds(1625234040))
|
||||
assertEquals(isRead, false)
|
||||
assertEquals(isStarred, false)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,284 @@
|
|||
package com.readrops.api.services.nextcloudnews
|
||||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.api.apiModule
|
||||
import com.readrops.api.enqueueOK
|
||||
import com.readrops.api.enqueueStream
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.KoinTestRule
|
||||
import org.koin.test.get
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class NextcloudNewsDataSourceTest : KoinTest {
|
||||
|
||||
private lateinit var nextcloudNewsDataSource: NextcloudNewsDataSource
|
||||
private val mockServer = MockWebServer()
|
||||
private val moshi = Moshi.Builder()
|
||||
.build()
|
||||
|
||||
@get:Rule
|
||||
val koinTestRule = KoinTestRule.create {
|
||||
modules(apiModule, module {
|
||||
single {
|
||||
Retrofit.Builder()
|
||||
.baseUrl("http://localhost:8080/")
|
||||
.client(get())
|
||||
.addConverterFactory(MoshiConverterFactory.create(get(named("nextcloudNewsMoshi"))))
|
||||
.build()
|
||||
.create(NextcloudNewsService::class.java)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
mockServer.start(8080)
|
||||
nextcloudNewsDataSource = NextcloudNewsDataSource(get())
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loginTest() = runTest {
|
||||
val stream = TestUtils.loadResource("services/nextcloudnews/user.xml")
|
||||
val account = Account(login = "login", url = mockServer.url("").toString())
|
||||
|
||||
mockServer.enqueueStream(stream)
|
||||
|
||||
val displayName = nextcloudNewsDataSource.login(get(), account)
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertTrue { displayName == "Shinokuni" }
|
||||
assertTrue { request.headers.contains("OCS-APIRequest" to "true") }
|
||||
assertTrue { request.path == "//ocs/v1.php/cloud/users/login" }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun foldersTest() = runTest {
|
||||
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json")
|
||||
mockServer.enqueueStream(stream)
|
||||
|
||||
val folders = nextcloudNewsDataSource.getFolders()
|
||||
assertTrue { folders.size == 1 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun feedsTest() = runTest {
|
||||
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json")
|
||||
mockServer.enqueueStream(stream)
|
||||
|
||||
val feeds = nextcloudNewsDataSource.getFeeds()
|
||||
assertTrue { feeds.size == 3 }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun itemsTest() = runTest {
|
||||
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
|
||||
mockServer.enqueueStream(stream)
|
||||
|
||||
val items = nextcloudNewsDataSource.getItems(NextcloudNewsDataSource.ItemQueryType.ALL.value, false, 10)
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertTrue { items.size == 3 }
|
||||
with(request.requestUrl!!) {
|
||||
assertEquals("3", queryParameter("type"))
|
||||
assertEquals("false", queryParameter("getRead"))
|
||||
assertEquals("10", queryParameter("batchSize"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun newItemsTest() = runTest {
|
||||
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/items.json")
|
||||
mockServer.enqueueStream(stream)
|
||||
|
||||
val items =
|
||||
nextcloudNewsDataSource.getNewItems(1512, NextcloudNewsDataSource.ItemQueryType.ALL)
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertTrue { items.size == 3 }
|
||||
with(request.requestUrl!!) {
|
||||
assertEquals("1512", queryParameter("lastModified"))
|
||||
assertEquals("3", queryParameter("type"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createFeedTest() = runTest {
|
||||
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/feeds.json")
|
||||
mockServer.enqueueStream(stream)
|
||||
|
||||
val feeds = nextcloudNewsDataSource.createFeed("https://news.ycombinator.com/rss", null)
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertTrue { feeds.isNotEmpty() }
|
||||
with(request.requestUrl!!) {
|
||||
assertEquals("https://news.ycombinator.com/rss", queryParameter("url"))
|
||||
assertEquals(null, queryParameter("folderId"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteFeedTest() = runTest {
|
||||
mockServer.enqueueOK()
|
||||
|
||||
nextcloudNewsDataSource.deleteFeed(15)
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertTrue { request.path!!.endsWith("/15") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun changeFeedFolderTest() = runTest {
|
||||
mockServer.enqueueOK()
|
||||
|
||||
nextcloudNewsDataSource.changeFeedFolder(15, 18)
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
val type =
|
||||
Types.newParameterizedType(
|
||||
Map::class.java,
|
||||
String::class.java,
|
||||
Int::class.javaObjectType
|
||||
)
|
||||
val adapter = moshi.adapter<Map<String, Int>>(type)
|
||||
val body = adapter.fromJson(request.body)!!
|
||||
|
||||
assertTrue { request.path!!.endsWith("/18/move") }
|
||||
assertEquals(15, body["folderId"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameFeedTest() = runTest {
|
||||
mockServer.enqueueOK()
|
||||
|
||||
nextcloudNewsDataSource.renameFeed("name", 15)
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
val type =
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
|
||||
val adapter = moshi.adapter<Map<String, String>>(type)
|
||||
val body = adapter.fromJson(request.body)!!
|
||||
|
||||
assertTrue { request.path!!.endsWith("/15/rename") }
|
||||
assertEquals("name", body["feedTitle"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createFolderTest() = runTest {
|
||||
val stream = TestUtils.loadResource("services/nextcloudnews/adapters/valid_folder.json")
|
||||
mockServer.enqueueStream(stream)
|
||||
|
||||
val folders = nextcloudNewsDataSource.createFolder("folder name")
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
val type =
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
|
||||
val adapter = moshi.adapter<Map<String, String>>(type)
|
||||
val body = adapter.fromJson(request.body)!!
|
||||
|
||||
assertTrue { folders.size == 1 }
|
||||
assertEquals("folder name", body["name"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renameFolderTest() = runTest {
|
||||
mockServer.enqueueOK()
|
||||
|
||||
nextcloudNewsDataSource.renameFolder("new name", 15)
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
val type =
|
||||
Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
|
||||
val adapter = moshi.adapter<Map<String, String>>(type)
|
||||
val body = adapter.fromJson(request.body)!!
|
||||
|
||||
assertTrue { request.path!!.endsWith("/15") }
|
||||
assertEquals("new name", body["name"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteFolderTest() = runTest {
|
||||
mockServer.enqueueOK()
|
||||
|
||||
nextcloudNewsDataSource.deleteFolder(15)
|
||||
val request = mockServer.takeRequest()
|
||||
|
||||
assertEquals(request.method, "DELETE")
|
||||
assertTrue { request.path!!.endsWith("/15") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setItemsReadStateTest() = runTest {
|
||||
mockServer.enqueueOK()
|
||||
mockServer.enqueueOK()
|
||||
|
||||
val data = NextcloudNewsSyncData(
|
||||
readIds = listOf(15, 16, 17),
|
||||
unreadIds = listOf(18, 19, 20)
|
||||
)
|
||||
|
||||
nextcloudNewsDataSource.setItemsReadState(data)
|
||||
val unreadRequest = mockServer.takeRequest()
|
||||
val readRequest = mockServer.takeRequest()
|
||||
|
||||
val type =
|
||||
Types.newParameterizedType(
|
||||
Map::class.java,
|
||||
String::class.java,
|
||||
Types.newParameterizedType(List::class.java, Int::class.javaObjectType)
|
||||
)
|
||||
val adapter = moshi.adapter<Map<String, List<Int>>>(type)
|
||||
val unreadBody = adapter.fromJson(unreadRequest.body)!!
|
||||
val readBody = adapter.fromJson(readRequest.body)!!
|
||||
|
||||
assertEquals(data.readIds, readBody["itemIds"])
|
||||
assertEquals(data.unreadIds, unreadBody["itemIds"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setItemsStarStateTest() = runTest {
|
||||
mockServer.enqueueOK()
|
||||
mockServer.enqueueOK()
|
||||
|
||||
val data = NextcloudNewsSyncData(
|
||||
starredIds = listOf(15, 16, 17),
|
||||
unstarredIds = listOf(18, 19, 20)
|
||||
)
|
||||
|
||||
nextcloudNewsDataSource.setItemsStarState(data)
|
||||
val starRequest = mockServer.takeRequest()
|
||||
val unstarRequest = mockServer.takeRequest()
|
||||
|
||||
val type =
|
||||
Types.newParameterizedType(
|
||||
Map::class.java,
|
||||
String::class.java,
|
||||
Types.newParameterizedType(List::class.java, Int::class.javaObjectType)
|
||||
)
|
||||
val adapter = moshi.adapter<Map<String, List<Int>>>(type)
|
||||
|
||||
val starBody = adapter.fromJson(starRequest.body)!!
|
||||
val unstarBody = adapter.fromJson(unstarRequest.body)!!
|
||||
|
||||
assertEquals(data.starredIds, starBody["itemIds"])
|
||||
assertEquals(data.unstarredIds, unstarBody["itemIds"])
|
||||
}
|
||||
}
|
|
@ -9,10 +9,10 @@ import org.junit.Assert.assertEquals
|
|||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class NextNewsFeedsAdapterTest {
|
||||
class NextcloudNewsFeedsAdapterTest {
|
||||
|
||||
private val adapter = Moshi.Builder()
|
||||
.add(NextNewsFeedsAdapter())
|
||||
.add(NextcloudNewsFeedsAdapter())
|
||||
.build()
|
||||
.adapter<List<Feed>>(Types.newParameterizedType(List::class.java, Feed::class.java))
|
||||
|
|
@ -10,10 +10,10 @@ import okio.Buffer
|
|||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class NextNewsFoldersAdapterTest {
|
||||
class NextcloudNewsFoldersAdapterTest {
|
||||
|
||||
private val adapter = Moshi.Builder()
|
||||
.add(NextNewsFoldersAdapter())
|
||||
.add(NextcloudNewsFoldersAdapter())
|
||||
.build()
|
||||
.adapter<List<Folder>>(Types.newParameterizedType(List::class.java, Folder::class.java))
|
||||
|
|
@ -2,17 +2,17 @@ package com.readrops.api.services.nextcloudnews.adapters
|
|||
|
||||
import com.readrops.api.TestUtils
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.util.DateUtils
|
||||
import com.squareup.moshi.Moshi
|
||||
import com.squareup.moshi.Types
|
||||
import junit.framework.TestCase.assertEquals
|
||||
import okio.Buffer
|
||||
import org.joda.time.LocalDateTime
|
||||
import org.junit.Test
|
||||
|
||||
class NextNewsItemsAdapterTest {
|
||||
class NextcloudNewsItemsAdapterTest {
|
||||
|
||||
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()
|
||||
.adapter<List<Item>>(Types.newParameterizedType(List::class.java, Item::class.java))
|
||||
|
||||
|
@ -25,7 +25,6 @@ class NextNewsItemsAdapterTest {
|
|||
|
||||
with(item) {
|
||||
assertEquals(remoteId, "3443")
|
||||
assertEquals(guid, "3059047a572cd9cd5d0bf645faffd077")
|
||||
assertEquals(link, "http://grulja.wordpress.com/2013/04/29/plasma-nm-after-the-solid-sprint/")
|
||||
assertEquals(title, "Plasma-nm after the solid sprint")
|
||||
assertEquals(author, "Jan Grulich (grulja)")
|
||||
|
@ -33,7 +32,7 @@ class NextNewsItemsAdapterTest {
|
|||
assertEquals(feedRemoteId, "67")
|
||||
assertEquals(isRead, false)
|
||||
assertEquals(isStarred, false)
|
||||
assertEquals(pubDate, LocalDateTime(1367270544000))
|
||||
assertEquals(pubDate, DateUtils.fromEpochSeconds(1367270544))
|
||||
assertEquals(imageLink, null)
|
||||
}
|
||||
|
|
@ -5,9 +5,9 @@ import com.readrops.api.TestUtils
|
|||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class NextNewsUserAdapterTest {
|
||||
class NextcloudNewsUserAdapterTest {
|
||||
|
||||
private val adapter = NextNewsUserAdapter()
|
||||
private val adapter = NextcloudNewsUserAdapter()
|
||||
|
||||
@Test
|
||||
fun validXmlTest() {
|
|
@ -1,58 +0,0 @@
|
|||
package com.readrops.api.utils;
|
||||
|
||||
import org.joda.time.LocalDateTime;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
public class DateUtilsTest {
|
||||
|
||||
@Test
|
||||
public void rssDateTest() {
|
||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
||||
|
||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46 GMT")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rssDate2Test() {
|
||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
||||
|
||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46 +0000")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rssDate3Test() {
|
||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
||||
|
||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 04 Jan 2019 22:21:46")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void atomJsonDateTest() {
|
||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
||||
|
||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("2019-01-04T22:21:46+00:00")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void atomJsonDate2Test() {
|
||||
LocalDateTime dateTime = new LocalDateTime(2019, 1, 4, 22, 21, 46);
|
||||
|
||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("2019-01-04T22:21:46-0000")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void isoPatternTest() {
|
||||
LocalDateTime dateTime = new LocalDateTime(2020, 6, 30, 11, 39, 37, 206);
|
||||
|
||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("2020-06-30T11:39:37.206-07:00")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void edtPatternTest() {
|
||||
LocalDateTime dateTime = new LocalDateTime(2020, 7, 17, 16, 30, 0);
|
||||
|
||||
assertEquals(0, dateTime.compareTo(DateUtils.parse("Fri, 17 Jul 2020 16:30:00 EDT")));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
package com.readrops.api.utils
|
||||
|
||||
import com.readrops.api.utils.exceptions.HttpException
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.net.HttpURLConnection
|
||||
|
||||
class ErrorInterceptorTest {
|
||||
|
||||
private val interceptor = ErrorInterceptor()
|
||||
private val server = MockWebServer()
|
||||
private lateinit var client: OkHttpClient
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
client = OkHttpClient.Builder()
|
||||
.addInterceptor(interceptor)
|
||||
.build()
|
||||
server.start(8080)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.close()
|
||||
}
|
||||
|
||||
@Test(expected = HttpException::class)
|
||||
fun interceptorTest() {
|
||||
server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
|
||||
|
||||
client.newCall(Request.Builder().url(server.url("/url")).build()).execute()
|
||||
//val request = server.takeRequest()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
package com.readrops.api.utils
|
||||
|
||||
import android.nfc.FormatException
|
||||
import com.readrops.api.TestUtils
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.KoinTestRule
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class HtmlParserTest : KoinTest {
|
||||
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
@get:Rule
|
||||
val koinTestRule = KoinTestRule.create {
|
||||
modules(module {
|
||||
single {
|
||||
OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.build()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@Test
|
||||
fun before() {
|
||||
mockServer.start()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun after() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFeedLinkTest() {
|
||||
val stream = TestUtils.loadResource("utils/file.html")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
val result =
|
||||
HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
|
||||
|
||||
assertTrue { result.size == 1 }
|
||||
assertTrue { result.first().url.endsWith("/rss") }
|
||||
assertEquals("RSS", result.first().label)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Test(expected = FormatException::class)
|
||||
fun getFeedLinkWithoutHeadTest() {
|
||||
val stream = TestUtils.loadResource("utils/file_without_head.html")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) }
|
||||
}
|
||||
|
||||
@Test(expected = FormatException::class)
|
||||
fun getFeedLinkNoHtmlFileTest() {
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/rss+xml"))
|
||||
|
||||
|
||||
runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFaviconLinkTest() {
|
||||
val stream = TestUtils.loadResource("utils/file.html")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
|
||||
|
||||
assertTrue { result!!.contains("favicon.ico") }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFaviconLinkWithoutHeadTest() {
|
||||
val stream = TestUtils.loadResource("utils/file_without_icon.html")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
runBlocking {
|
||||
val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,7 +18,10 @@
|
|||
],
|
||||
"categories": [
|
||||
"user/-/state/com.google/reading-list",
|
||||
"user/-/label/Libre"
|
||||
"user/-/label/Libre",
|
||||
"category1",
|
||||
"category2",
|
||||
"category3"
|
||||
],
|
||||
"origin": {
|
||||
"streamId": "feed/15",
|
||||
|
@ -44,7 +47,10 @@
|
|||
"user/-/state/com.google/reading-list",
|
||||
"user/-/label/Libre",
|
||||
"user/-/state/com.google/starred",
|
||||
"user/-/state/com.google/read"
|
||||
"user/-/state/com.google/read",
|
||||
"category1",
|
||||
"category2",
|
||||
"category3"
|
||||
],
|
||||
"origin": {
|
||||
"streamId": "feed/15",
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
SID=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a
|
||||
LSID=null
|
||||
Auth=login/p1f8vmzid4hzzxf31mgx50gt8pnremgp4z8xe44a
|
|
@ -0,0 +1 @@
|
|||
PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg
|
|
@ -0,0 +1,601 @@
|
|||
<html lang="en" op="news">
|
||||
<head>
|
||||
<meta name="referrer" content="origin">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" type="text/css" href="news.css?t8fsBYOw2Gz0ODjGokUo">
|
||||
<link rel="shortcut icon" href="favicon.ico">
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="rss">
|
||||
<title>Hacker News</title>
|
||||
</head>
|
||||
<body>
|
||||
<center>
|
||||
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
|
||||
<tr>
|
||||
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
|
||||
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
|
||||
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
|
||||
</td>
|
||||
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
|
||||
<a href="login?goto=news">login</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="pagespace" title="" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr class='athing' id='36826210'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826210'href='vote?id=36826210&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&goto=news">hide</a> | <a href="item?id=36826210">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36813688'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36813688'href='vote?id=36813688&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&goto=news">hide</a> | <a href="item?id=36813688">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797650'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797650'href='vote?id=36797650&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&goto=news">hide</a> | <a href="item?id=36797650">26 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36827034'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36827034'href='vote?id=36827034&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&goto=news">hide</a> | <a href="item?id=36827034">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823565'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823565'href='vote?id=36823565&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple's interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&goto=news">hide</a> | <a href="item?id=36823565">20 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823605'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823605'href='vote?id=36823605&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&goto=news">hide</a> | <a href="item?id=36823605">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826177'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826177'href='vote?id=36826177&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&goto=news">hide</a> | <a href="item?id=36826177">10 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824595'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824595'href='vote?id=36824595&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos – HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&goto=news">hide</a> | <a href="item?id=36824595">23 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825992'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825992'href='vote?id=36825992&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you've ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&goto=news">hide</a> | <a href="item?id=36825992">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825345'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825345'href='vote?id=36825345&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&goto=news">hide</a> | <a href="item?id=36825345">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823516'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823516'href='vote?id=36823516&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&goto=news">hide</a> | <a href="item?id=36823516">66 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824450'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824450'href='vote?id=36824450&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane – Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&goto=news">hide</a> | <a href="item?id=36824450">93 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825481'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825481'href='vote?id=36825481&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&goto=news">hide</a> | <a href="item?id=36825481">15 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823375'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823375'href='vote?id=36823375&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&goto=news">hide</a> | <a href="item?id=36823375">22 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823524'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823524'href='vote?id=36823524&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe – Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&goto=news">hide</a> | <a href="item?id=36823524">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824607'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824607'href='vote?id=36824607&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&goto=news">hide</a> | <a href="item?id=36824607">43 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825913'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825913'href='vote?id=36825913&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine – open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&goto=news">hide</a> | <a href="item?id=36825913">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797471'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797471'href='vote?id=36797471&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&goto=news">hide</a> | <a href="item?id=36797471">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825204'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825204'href='vote?id=36825204&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&goto=news">hide</a> | <a href="item?id=36825204">72 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822880'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822880'href='vote?id=36822880&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&goto=news">hide</a> | <a href="item?id=36822880">58 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36803767'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36803767'href='vote?id=36803767&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&goto=news">hide</a> | <a href="item?id=36803767">18 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824330'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824330'href='vote?id=36824330&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&goto=news">hide</a> | <a href="item?id=36824330">11 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826111'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826111'href='vote?id=36826111&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&goto=news">hide</a> | <a href="item?id=36826111">121 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36784114'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36784114'href='vote?id=36784114&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&goto=news">hide</a> | <a href="item?id=36784114">75 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36808566'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36808566'href='vote?id=36808566&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823723'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823723'href='vote?id=36823723&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&goto=news">hide</a> | <a href="item?id=36823723">107 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824856'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824856'href='vote?id=36824856&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&goto=news">hide</a> | <a href="item?id=36824856">55 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822530'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822530'href='vote?id=36822530&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&goto=news">hide</a> | <a href="item?id=36822530">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36783937'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36783937'href='vote?id=36783937&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&goto=news">hide</a> | <a href="item?id=36783937">57 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826664'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826664'href='vote?id=36826664&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class="morespace" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="s.gif" height="10" width="0">
|
||||
<table width="100%" cellspacing="0" cellpadding="1">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
<center>
|
||||
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
|
||||
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,593 @@
|
|||
<html lang="en" op="news">
|
||||
<body>
|
||||
<center>
|
||||
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
|
||||
<tr>
|
||||
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
|
||||
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
|
||||
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
|
||||
</td>
|
||||
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
|
||||
<a href="login?goto=news">login</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="pagespace" title="" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr class='athing' id='36826210'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826210'href='vote?id=36826210&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&goto=news">hide</a> | <a href="item?id=36826210">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36813688'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36813688'href='vote?id=36813688&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&goto=news">hide</a> | <a href="item?id=36813688">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797650'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797650'href='vote?id=36797650&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&goto=news">hide</a> | <a href="item?id=36797650">26 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36827034'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36827034'href='vote?id=36827034&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&goto=news">hide</a> | <a href="item?id=36827034">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823565'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823565'href='vote?id=36823565&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple's interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&goto=news">hide</a> | <a href="item?id=36823565">20 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823605'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823605'href='vote?id=36823605&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&goto=news">hide</a> | <a href="item?id=36823605">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826177'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826177'href='vote?id=36826177&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&goto=news">hide</a> | <a href="item?id=36826177">10 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824595'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824595'href='vote?id=36824595&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos – HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&goto=news">hide</a> | <a href="item?id=36824595">23 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825992'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825992'href='vote?id=36825992&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you've ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&goto=news">hide</a> | <a href="item?id=36825992">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825345'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825345'href='vote?id=36825345&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&goto=news">hide</a> | <a href="item?id=36825345">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823516'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823516'href='vote?id=36823516&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&goto=news">hide</a> | <a href="item?id=36823516">66 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824450'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824450'href='vote?id=36824450&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane – Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&goto=news">hide</a> | <a href="item?id=36824450">93 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825481'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825481'href='vote?id=36825481&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&goto=news">hide</a> | <a href="item?id=36825481">15 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823375'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823375'href='vote?id=36823375&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&goto=news">hide</a> | <a href="item?id=36823375">22 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823524'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823524'href='vote?id=36823524&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe – Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&goto=news">hide</a> | <a href="item?id=36823524">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824607'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824607'href='vote?id=36824607&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&goto=news">hide</a> | <a href="item?id=36824607">43 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825913'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825913'href='vote?id=36825913&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine – open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&goto=news">hide</a> | <a href="item?id=36825913">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797471'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797471'href='vote?id=36797471&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&goto=news">hide</a> | <a href="item?id=36797471">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825204'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825204'href='vote?id=36825204&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&goto=news">hide</a> | <a href="item?id=36825204">72 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822880'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822880'href='vote?id=36822880&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&goto=news">hide</a> | <a href="item?id=36822880">58 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36803767'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36803767'href='vote?id=36803767&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&goto=news">hide</a> | <a href="item?id=36803767">18 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824330'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824330'href='vote?id=36824330&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&goto=news">hide</a> | <a href="item?id=36824330">11 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826111'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826111'href='vote?id=36826111&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&goto=news">hide</a> | <a href="item?id=36826111">121 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36784114'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36784114'href='vote?id=36784114&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&goto=news">hide</a> | <a href="item?id=36784114">75 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36808566'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36808566'href='vote?id=36808566&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823723'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823723'href='vote?id=36823723&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&goto=news">hide</a> | <a href="item?id=36823723">107 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824856'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824856'href='vote?id=36824856&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&goto=news">hide</a> | <a href="item?id=36824856">55 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822530'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822530'href='vote?id=36822530&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&goto=news">hide</a> | <a href="item?id=36822530">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36783937'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36783937'href='vote?id=36783937&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&goto=news">hide</a> | <a href="item?id=36783937">57 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826664'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826664'href='vote?id=36826664&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class="morespace" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="s.gif" height="10" width="0">
|
||||
<table width="100%" cellspacing="0" cellpadding="1">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
<center>
|
||||
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
|
||||
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,600 @@
|
|||
<html lang="en" op="news">
|
||||
<head>
|
||||
<meta name="referrer" content="origin">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" type="text/css" href="news.css?t8fsBYOw2Gz0ODjGokUo">
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="rss">
|
||||
<title>Hacker News</title>
|
||||
</head>
|
||||
<body>
|
||||
<center>
|
||||
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600">
|
||||
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
|
||||
<tr>
|
||||
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
|
||||
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
|
||||
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
|
||||
</td>
|
||||
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
|
||||
<a href="login?goto=news">login</a>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="pagespace" title="" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td>
|
||||
<table border="0" cellpadding="0" cellspacing="0">
|
||||
<tr class='athing' id='36826210'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826210'href='vote?id=36826210&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&goto=news">hide</a> | <a href="item?id=36826210">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36813688'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36813688'href='vote?id=36813688&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&goto=news">hide</a> | <a href="item?id=36813688">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797650'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797650'href='vote?id=36797650&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&goto=news">hide</a> | <a href="item?id=36797650">26 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36827034'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36827034'href='vote?id=36827034&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&goto=news">hide</a> | <a href="item?id=36827034">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823565'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823565'href='vote?id=36823565&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple's interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&goto=news">hide</a> | <a href="item?id=36823565">20 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823605'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823605'href='vote?id=36823605&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&goto=news">hide</a> | <a href="item?id=36823605">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826177'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826177'href='vote?id=36826177&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&goto=news">hide</a> | <a href="item?id=36826177">10 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824595'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824595'href='vote?id=36824595&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos – HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&goto=news">hide</a> | <a href="item?id=36824595">23 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825992'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825992'href='vote?id=36825992&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you've ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&goto=news">hide</a> | <a href="item?id=36825992">3 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825345'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825345'href='vote?id=36825345&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&goto=news">hide</a> | <a href="item?id=36825345">1 comment</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823516'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823516'href='vote?id=36823516&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&goto=news">hide</a> | <a href="item?id=36823516">66 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824450'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824450'href='vote?id=36824450&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane – Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&goto=news">hide</a> | <a href="item?id=36824450">93 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825481'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825481'href='vote?id=36825481&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&goto=news">hide</a> | <a href="item?id=36825481">15 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823375'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823375'href='vote?id=36823375&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&goto=news">hide</a> | <a href="item?id=36823375">22 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823524'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823524'href='vote?id=36823524&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe – Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&goto=news">hide</a> | <a href="item?id=36823524">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824607'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824607'href='vote?id=36824607&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&goto=news">hide</a> | <a href="item?id=36824607">43 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825913'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825913'href='vote?id=36825913&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine – open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&goto=news">hide</a> | <a href="item?id=36825913">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36797471'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36797471'href='vote?id=36797471&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&goto=news">hide</a> | <a href="item?id=36797471">73 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36825204'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36825204'href='vote?id=36825204&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&goto=news">hide</a> | <a href="item?id=36825204">72 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822880'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822880'href='vote?id=36822880&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&goto=news">hide</a> | <a href="item?id=36822880">58 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36803767'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36803767'href='vote?id=36803767&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&goto=news">hide</a> | <a href="item?id=36803767">18 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824330'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824330'href='vote?id=36824330&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&goto=news">hide</a> | <a href="item?id=36824330">11 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826111'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826111'href='vote?id=36826111&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&goto=news">hide</a> | <a href="item?id=36826111">121 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36784114'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36784114'href='vote?id=36784114&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&goto=news">hide</a> | <a href="item?id=36784114">75 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36808566'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36808566'href='vote?id=36808566&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusai’s Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36823723'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36823723'href='vote?id=36823723&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&goto=news">hide</a> | <a href="item?id=36823723">107 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36824856'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36824856'href='vote?id=36824856&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&goto=news">hide</a> | <a href="item?id=36824856">55 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36822530'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36822530'href='vote?id=36822530&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&goto=news">hide</a> | <a href="item?id=36822530">24 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36783937'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36783937'href='vote?id=36783937&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&goto=news">hide</a> | <a href="item?id=36783937">57 comments</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class='athing' id='36826664'>
|
||||
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
|
||||
<td valign="top" class="votelinks">
|
||||
<center>
|
||||
<a id='up_36826664'href='vote?id=36826664&how=up&goto=news'>
|
||||
<div class='votearrow' title='upvote'></div>
|
||||
</a>
|
||||
</center>
|
||||
</td>
|
||||
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class="subtext"><span class="subline">
|
||||
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="spacer" style="height:5px"></tr>
|
||||
<tr class="morespace" style="height:10px"></tr>
|
||||
<tr>
|
||||
<td colspan="2"></td>
|
||||
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<img src="s.gif" height="10" width="0">
|
||||
<table width="100%" cellspacing="0" cellpadding="1">
|
||||
<tr>
|
||||
<td bgcolor="#ff6600"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<br>
|
||||
<center>
|
||||
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
|
||||
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</center>
|
||||
</body>
|
||||
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
|
||||
</html>
|
||||
|
|
@ -1 +1 @@
|
|||
/build
|
||||
/build
|
106
app/build.gradle
106
app/build.gradle
|
@ -1,106 +0,0 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
|
||||
android {
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.readrops.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
buildToolsVersion rootProject.ext.buildToolsVersion
|
||||
|
||||
versionCode 14
|
||||
versionName "1.3.1"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
testOptions {
|
||||
unitTests.returnDefaultValues = true
|
||||
}
|
||||
lintOptions {
|
||||
abortOnError false
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled true
|
||||
shrinkResources true
|
||||
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
|
||||
debug {
|
||||
minifyEnabled false
|
||||
shrinkResources false
|
||||
|
||||
testCoverageEnabled true
|
||||
applicationIdSuffix ".debug"
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
coreLibraryDesugaringEnabled true
|
||||
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = '1.8'
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(dir: 'libs', include: ['*.jar'])
|
||||
implementation project(':api')
|
||||
implementation project(':db')
|
||||
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
|
||||
|
||||
testImplementation 'junit:junit:4.13'
|
||||
androidTestImplementation 'androidx.test.ext:junit:1.1.3'
|
||||
androidTestImplementation 'androidx.test:runner:1.4.0'
|
||||
androidTestImplementation 'androidx.test:rules:1.4.0'
|
||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
|
||||
|
||||
implementation 'com.google.android.material:material:1.4.0'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.palette:palette-ktx:1.0.0'
|
||||
implementation 'androidx.recyclerview:recyclerview:1.2.1'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
|
||||
implementation 'androidx.legacy:legacy-support-v4:1.0.0'
|
||||
implementation 'androidx.preference:preference:1.1.1'
|
||||
implementation "androidx.work:work-runtime-ktx:2.5.0"
|
||||
implementation "androidx.fragment:fragment-ktx:1.3.5"
|
||||
implementation "androidx.browser:browser:1.3.0"
|
||||
|
||||
testImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
|
||||
testImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
|
||||
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
kapt 'com.github.bumptech.glide:compiler:4.12.0'
|
||||
implementation 'com.github.bumptech.glide:okhttp3-integration:4.12.0'
|
||||
implementation('com.github.bumptech.glide:recyclerview-integration:4.12.0') {
|
||||
transitive = false
|
||||
}
|
||||
|
||||
implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0'
|
||||
kapt 'androidx.lifecycle:lifecycle-common-java8:2.3.1'
|
||||
|
||||
implementation 'com.afollestad.material-dialogs:core:0.9.6.0'
|
||||
|
||||
implementation 'com.mikepenz:fastadapter:3.2.9'
|
||||
implementation 'com.mikepenz:fastadapter-commons:3.3.0'
|
||||
implementation 'com.mikepenz:materialdrawer:6.1.2'
|
||||
implementation "com.mikepenz:aboutlibraries:6.2.3"
|
||||
implementation "com.mikepenz:iconics-views:3.2.5"
|
||||
implementation "com.mikepenz:iconics-core:3.2.5"
|
||||
|
||||
debugImplementation 'com.facebook.flipper:flipper:0.96.1'
|
||||
debugImplementation 'com.facebook.soloader:soloader:0.10.1'
|
||||
debugImplementation 'com.facebook.flipper:flipper-network-plugin:0.96.1'
|
||||
}
|
|
@ -0,0 +1,90 @@
|
|||
plugins {
|
||||
id("com.android.application")
|
||||
kotlin("android")
|
||||
alias(libs.plugins.compose.compiler)
|
||||
id("com.mikepenz.aboutlibraries.plugin")
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
namespace = "com.readrops.app"
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.readrops.app"
|
||||
|
||||
versionCode = 15
|
||||
versionName = "2.0-beta01"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
|
||||
debug {
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
|
||||
applicationIdSuffix = ".debug"
|
||||
enableUnitTestCoverage = true
|
||||
enableAndroidTestCoverage = true
|
||||
|
||||
proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError = false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":api"))
|
||||
implementation(project(":db"))
|
||||
|
||||
coreLibraryDesugaring(libs.jdk.desugar)
|
||||
|
||||
implementation(libs.corektx)
|
||||
implementation(libs.appcompat)
|
||||
implementation(libs.material)
|
||||
implementation(libs.palette)
|
||||
implementation(libs.workmanager)
|
||||
implementation(libs.encrypted.preferences)
|
||||
implementation(libs.datastore)
|
||||
implementation(libs.browser)
|
||||
|
||||
implementation(libs.jsoup)
|
||||
|
||||
testImplementation(libs.junit4)
|
||||
androidTestImplementation(libs.bundles.test)
|
||||
|
||||
implementation(platform(libs.compose.bom))
|
||||
implementation(libs.bundles.compose)
|
||||
|
||||
implementation(libs.bundles.voyager)
|
||||
implementation(libs.bundles.lifecycle)
|
||||
implementation(libs.bundles.coil)
|
||||
|
||||
implementation(libs.bundles.coroutines)
|
||||
androidTestImplementation(libs.coroutines.test)
|
||||
|
||||
implementation(libs.bundles.room)
|
||||
implementation(libs.bundles.paging)
|
||||
|
||||
implementation(platform(libs.koin.bom))
|
||||
implementation(libs.bundles.koin)
|
||||
//androidTestImplementation(libs.bundles.kointest)
|
||||
// I don't know why but those dependencies are unreachable when accessed directly from version catalog
|
||||
androidTestImplementation("io.insert-koin:koin-test:${libs.versions.koin.bom.get()}")
|
||||
androidTestImplementation("io.insert-koin:koin-test-junit4:${libs.versions.koin.bom.get()}")
|
||||
|
||||
androidTestImplementation(libs.okhttp.mockserver)
|
||||
|
||||
implementation(libs.aboutlibraries.composem3)
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
# Add project specific ProGuard rules here.
|
||||
# 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
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
@ -31,4 +31,21 @@
|
|||
|
||||
-keep class com.readrops.api.localfeed.** { *; }
|
||||
|
||||
-keep class com.readrops.api.opml.model.** { *; }
|
||||
-keep class com.readrops.api.opml.model.** { *; }
|
||||
|
||||
# Please add these rules to your existing keep rules in order to suppress warnings.
|
||||
# This is generated automatically by the Android Gradle plugin.
|
||||
-dontwarn javax.xml.stream.Location
|
||||
-dontwarn javax.xml.stream.XMLInputFactory
|
||||
-dontwarn javax.xml.stream.XMLStreamReader
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLParameters
|
||||
-dontwarn org.bouncycastle.jsse.BCSSLSocket
|
||||
-dontwarn org.bouncycastle.jsse.provider.BouncyCastleJsseProvider
|
||||
-dontwarn org.conscrypt.Conscrypt$Version
|
||||
-dontwarn org.conscrypt.Conscrypt
|
||||
-dontwarn org.conscrypt.ConscryptHostnameVerifier
|
||||
-dontwarn org.joda.convert.FromString
|
||||
-dontwarn org.joda.convert.ToString
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLParameters
|
||||
-dontwarn org.openjsse.javax.net.ssl.SSLSocket
|
||||
-dontwarn org.openjsse.net.ssl.OpenJSSE
|
|
@ -0,0 +1,58 @@
|
|||
package com.readrops.app
|
||||
|
||||
import android.content.Context
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.readrops.api.apiModule
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.app.util.FeedColors
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTestRule
|
||||
import java.net.HttpURLConnection
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class FeedColorsTest {
|
||||
|
||||
private val mockServer = MockWebServer()
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
|
||||
KoinTestRule.create {
|
||||
modules(apiModule, module {
|
||||
single { context }
|
||||
})
|
||||
}
|
||||
|
||||
mockServer.start()
|
||||
}
|
||||
|
||||
@After
|
||||
fun after() {
|
||||
mockServer.shutdown()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFeedColorTest() = runBlocking {
|
||||
val stream = TestUtils.loadResource("favicon.ico")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "image/jpeg")
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
val url = mockServer.url("/rss").toString()
|
||||
val color = FeedColors.getFeedColor(url)
|
||||
|
||||
assertTrue { color != 0 }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,86 @@
|
|||
package com.readrops.app
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.readrops.app.repositories.GetFoldersWithFeeds
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
import com.readrops.db.filters.MainFilter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.time.LocalDateTime
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GetFoldersWithFeedsTest {
|
||||
|
||||
private lateinit var database: Database
|
||||
private lateinit var getFoldersWithFeeds: GetFoldersWithFeeds
|
||||
private val account = Account(accountType = AccountType.LOCAL)
|
||||
|
||||
@Before
|
||||
fun before() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
|
||||
|
||||
runTest {
|
||||
account.id = database.accountDao().insert(account).toInt()
|
||||
|
||||
// inserting 3 folders
|
||||
repeat(3) { time ->
|
||||
database.folderDao()
|
||||
.insert(Folder(name = "Folder $time", accountId = account.id))
|
||||
}
|
||||
|
||||
// inserting 2 feeds, not linked to any folder
|
||||
repeat(2) { time ->
|
||||
database.feedDao().insert(Feed(name = "Feed $time", accountId = account.id))
|
||||
}
|
||||
|
||||
// inserting 2 feeds linked to first folder (Folder 0)
|
||||
repeat(2) { time ->
|
||||
database.feedDao()
|
||||
.insert(Feed(name = "Feed ${time + 2}", folderId = 1, accountId = account.id))
|
||||
}
|
||||
|
||||
// inserting 3 unread items linked to first feed (Feed 0)
|
||||
repeat(3) { time ->
|
||||
database.itemDao()
|
||||
.insert(Item(title = "Item $time", feedId = 1, pubDate = LocalDateTime.now()))
|
||||
}
|
||||
|
||||
// insert 3 read items items linked to second feed (feed 1)
|
||||
repeat(3) { time ->
|
||||
database.itemDao()
|
||||
.insert(
|
||||
Item(
|
||||
title = "Item ${time + 3}",
|
||||
feedId = 3,
|
||||
isRead = true,
|
||||
pubDate = LocalDateTime.now()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getFoldersWithFeedsTest() = runTest {
|
||||
getFoldersWithFeeds = GetFoldersWithFeeds(database)
|
||||
val foldersAndFeeds =
|
||||
getFoldersWithFeeds.get(account.id, MainFilter.ALL, account.config.useSeparateState)
|
||||
.first()
|
||||
|
||||
assertTrue { foldersAndFeeds.size == 4 }
|
||||
assertTrue { foldersAndFeeds.entries.first().value.size == 2 }
|
||||
assertTrue { foldersAndFeeds.entries.last().key == null }
|
||||
assertTrue { foldersAndFeeds[null]!!.size == 2 }
|
||||
assertTrue { foldersAndFeeds[null]!!.first().unreadCount == 3 }
|
||||
}
|
||||
}
|
|
@ -0,0 +1,116 @@
|
|||
package com.readrops.app
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Room
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.readrops.api.apiModule
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.api.utils.AuthInterceptor
|
||||
import com.readrops.app.repositories.LocalRSSRepository
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okio.Buffer
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.koin.dsl.module
|
||||
import org.koin.test.KoinTest
|
||||
import org.koin.test.KoinTestRule
|
||||
import org.koin.test.get
|
||||
import java.net.HttpURLConnection
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
|
||||
class LocalRSSRepositoryTest : KoinTest {
|
||||
|
||||
private val mockServer: MockWebServer = MockWebServer()
|
||||
private val account = Account(accountType = AccountType.LOCAL)
|
||||
private lateinit var database: Database
|
||||
private lateinit var repository: LocalRSSRepository
|
||||
private lateinit var feeds: List<Feed>
|
||||
|
||||
@Before
|
||||
fun before() = runTest {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
database = Room.inMemoryDatabaseBuilder(context, Database::class.java).build()
|
||||
|
||||
KoinTestRule.create {
|
||||
modules(apiModule, module {
|
||||
single { database }
|
||||
single {
|
||||
OkHttpClient.Builder()
|
||||
.callTimeout(1, TimeUnit.MINUTES)
|
||||
.readTimeout(1, TimeUnit.HOURS)
|
||||
.addInterceptor(get<AuthInterceptor>())
|
||||
.build()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
mockServer.start()
|
||||
val url = mockServer.url("/rss")
|
||||
|
||||
account.id = database.accountDao().insert(account).toInt()
|
||||
feeds = listOf(
|
||||
Feed(
|
||||
name = "feedTest",
|
||||
url = url.toString(),
|
||||
accountId = account.id,
|
||||
),
|
||||
)
|
||||
|
||||
database.feedDao().insert(feeds).apply {
|
||||
feeds.first().id = first().toInt()
|
||||
}
|
||||
|
||||
repository = LocalRSSRepository(get(), database, account)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun synchronizeTest() = runTest {
|
||||
val stream = TestUtils.loadResource("rss_feed.xml")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8")
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
val result = repository.synchronize(listOf()) {
|
||||
assertEquals(it.name, feeds.first().name)
|
||||
}
|
||||
|
||||
assertTrue { result.first.items.isNotEmpty() }
|
||||
assertTrue {
|
||||
database.itemDao().itemExists(result.first.items.first().remoteId!!, account.id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun synchronizeWithFeedsTest(): Unit = runBlocking {
|
||||
val stream = TestUtils.loadResource("rss_feed.xml")
|
||||
|
||||
mockServer.enqueue(
|
||||
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
|
||||
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/xml; charset=UTF-8")
|
||||
.setBody(Buffer().readFrom(stream))
|
||||
)
|
||||
|
||||
val result = repository.synchronize(feeds) {
|
||||
assertEquals(it.name, feeds.first().name)
|
||||
}
|
||||
|
||||
assertTrue { result.first.items.isNotEmpty() }
|
||||
assertTrue {
|
||||
database.itemDao().itemExists(result.first.items.first().remoteId!!, account.id)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,72 +4,73 @@ import android.content.Context
|
|||
import androidx.room.Room
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
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.entities.Feed
|
||||
import com.readrops.db.entities.Item
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
import com.readrops.api.services.SyncResult
|
||||
import org.joda.time.LocalDateTime
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import java.time.LocalDateTime
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import kotlin.test.assertNull
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncResultAnalyserTest {
|
||||
class SyncAnalyzerTest {
|
||||
|
||||
private lateinit var database: Database
|
||||
|
||||
private lateinit var syncAnalyzer: SyncAnalyzer
|
||||
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 {
|
||||
accountName = "test account 2"
|
||||
accountType = AccountType.NEXTCLOUD_NEWS
|
||||
private val nullContentException =
|
||||
NullPointerException("Notification content shouldn't be null")
|
||||
|
||||
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
|
||||
}
|
||||
)
|
||||
|
||||
private val account3 = Account().apply {
|
||||
accountName = "test account 3"
|
||||
accountType = AccountType.LOCAL
|
||||
private val account3 = Account(
|
||||
accountName = "test account 3",
|
||||
accountType = AccountType.LOCAL,
|
||||
isNotificationsEnabled = true
|
||||
}
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setupDb() {
|
||||
fun setupDb() = runTest {
|
||||
database = Room.inMemoryDatabaseBuilder(context, Database::class.java)
|
||||
.build()
|
||||
.build()
|
||||
|
||||
var account1Id = 0
|
||||
database.accountDao().insert(account1).subscribe { id -> account1Id = id.toInt() }
|
||||
account1.id = account1Id
|
||||
syncAnalyzer = SyncAnalyzer(context, database)
|
||||
|
||||
var account2Id = 0
|
||||
database.accountDao().insert(account2).subscribe { id -> account2Id = id.toInt() }
|
||||
account2.id = account2Id
|
||||
account1.id = database.accountDao().insert(account1).toInt()
|
||||
account2.id = database.accountDao().insert(account2).toInt()
|
||||
account3.id = database.accountDao().insert(account3).toInt()
|
||||
|
||||
var account3Id = 0
|
||||
database.accountDao().insert(account3).subscribe { id -> account3Id = id.toInt() }
|
||||
account3.id = account3Id
|
||||
|
||||
val accountIds = listOf(account1Id, account2Id, account3Id)
|
||||
val accountIds = listOf(account1.id, account2.id, account3.id)
|
||||
for (i in 0..2) {
|
||||
val feed = Feed().apply {
|
||||
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) }!!
|
||||
isNotificationEnabled = i % 2 == 0
|
||||
}
|
||||
|
||||
database.feedDao().insert(feed).subscribe()
|
||||
database.feedDao().insert(feed)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -79,228 +80,194 @@ class SyncResultAnalyserTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
fun testOneElementEveryWhere() {
|
||||
val item = Item().apply {
|
||||
title = "caseOneElementEveryWhere"
|
||||
feedId = 1
|
||||
remoteId = "item 1"
|
||||
fun testOneElementEveryWhere() = runTest {
|
||||
val item = Item(
|
||||
title = "caseOneElementEveryWhere",
|
||||
feedId = 1,
|
||||
remoteId = "item 1",
|
||||
pubDate = LocalDateTime.now()
|
||||
}
|
||||
)
|
||||
|
||||
database.itemDao()
|
||||
.insert(item)
|
||||
.subscribe()
|
||||
database.itemDao().insert(item)
|
||||
|
||||
val syncResult = SyncResult().apply { items = mutableListOf(item) }
|
||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent()
|
||||
val syncResult = SyncResult(items = listOf(item))
|
||||
|
||||
assertEquals("caseOneElementEveryWhere", notifContent.content)
|
||||
assertEquals("feed 1", notifContent.title)
|
||||
assertTrue(notifContent.largeIcon != null)
|
||||
assertTrue(notifContent.accountId!! > 0)
|
||||
syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
|
||||
assertEquals("caseOneElementEveryWhere", content.text)
|
||||
assertEquals("feed 1", content.title)
|
||||
assertTrue(content.largeIcon != null)
|
||||
assertTrue(content.accountId > 0)
|
||||
} ?: throw nullContentException
|
||||
|
||||
database.itemDao()
|
||||
.delete(item)
|
||||
.subscribe()
|
||||
database.itemDao().delete(item)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTwoItemsOneFeed() {
|
||||
val item = Item().apply {
|
||||
title = "caseTwoItemsOneFeed"
|
||||
feedId = 1
|
||||
}
|
||||
fun testTwoItemsOneFeed() = runTest {
|
||||
val item = Item(title = "caseTwoItemsOneFeed", feedId = 1)
|
||||
val syncResult = SyncResult(items = listOf(item, item, item))
|
||||
|
||||
val syncResult = SyncResult().apply { items = listOf(item, item, item) }
|
||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent()
|
||||
|
||||
assertEquals(context.getString(R.string.new_items, 3), notifContent.content)
|
||||
assertEquals("feed 1", notifContent.title)
|
||||
assertTrue(notifContent.largeIcon != null)
|
||||
assertTrue(notifContent.accountId!! > 0)
|
||||
syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
|
||||
assertEquals(context.getString(R.string.new_items, 3), content.text)
|
||||
assertEquals("feed 1", content.title)
|
||||
assertTrue(content.largeIcon != null)
|
||||
assertTrue(content.accountId > 0)
|
||||
} ?: throw nullContentException
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMultipleFeeds() {
|
||||
val item = Item().apply { feedId = 1 }
|
||||
val item2 = Item().apply { feedId = 3 }
|
||||
fun testMultipleFeeds() = runTest {
|
||||
val item = Item(feedId = 1)
|
||||
val item2 = Item(feedId = 3)
|
||||
|
||||
val syncResult = SyncResult().apply { items = listOf(item, item2) }
|
||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent()
|
||||
val syncResult = SyncResult(items = listOf(item, item2))
|
||||
|
||||
assertEquals(context.getString(R.string.new_items, 2), notifContent.content)
|
||||
assertEquals(account1.accountName, notifContent.title)
|
||||
assertTrue(notifContent.largeIcon != null)
|
||||
assertTrue(notifContent.accountId!! > 0)
|
||||
syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))?.let { content ->
|
||||
assertEquals(context.getString(R.string.new_items, 2), content.text)
|
||||
assertEquals(account1.accountName, content.title)
|
||||
assertTrue(content.largeIcon != null)
|
||||
assertTrue(content.accountId > 0)
|
||||
} ?: throw nullContentException
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMultipleAccounts() {
|
||||
val item = Item().apply { feedId = 1 }
|
||||
val item2 = Item().apply { feedId = 3 }
|
||||
fun testMultipleAccounts() = runTest {
|
||||
val item = Item(feedId = 1)
|
||||
val item2 = Item(feedId = 3)
|
||||
|
||||
val syncResult = SyncResult().apply { items = listOf(item, item2) }
|
||||
val syncResult2 = SyncResult().apply { items = listOf(item, item2) }
|
||||
val syncResult = SyncResult(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 {
|
||||
put(account1, syncResult)
|
||||
put(account3, syncResult2)
|
||||
}
|
||||
|
||||
val notifContent = SyncResultAnalyser(context, syncResults, database).getSyncNotifContent()
|
||||
|
||||
assertEquals(context.getString(R.string.new_items, 4), notifContent.title)
|
||||
syncAnalyzer.getNotificationContent(syncResults)?.let { content ->
|
||||
assertEquals(context.getString(R.string.new_items, 4), content.title)
|
||||
} ?: throw nullContentException
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testAccountNotificationsDisabled() {
|
||||
val item1 = Item().apply {
|
||||
title = "testAccountNotificationsDisabled"
|
||||
feedId = 1
|
||||
}
|
||||
fun testAccountNotificationsDisabled() = runTest {
|
||||
val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 1)
|
||||
val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 1)
|
||||
|
||||
val item2 = Item().apply {
|
||||
title = "testAccountNotificationsDisabled2"
|
||||
feedId = 1
|
||||
}
|
||||
val syncResult = SyncResult(items = listOf(item1, item2))
|
||||
|
||||
val syncResult = SyncResult().apply { items = listOf(item1, item2) }
|
||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account2, syncResult)), database).getSyncNotifContent()
|
||||
|
||||
assert(notifContent.title == null)
|
||||
assert(notifContent.content == null)
|
||||
assert(notifContent.largeIcon == null)
|
||||
val content = syncAnalyzer.getNotificationContent(mapOf(account2 to syncResult))
|
||||
assertNull(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFeedNotificationsDisabled() {
|
||||
val item1 = Item().apply {
|
||||
title = "testAccountNotificationsDisabled"
|
||||
feedId = 2
|
||||
}
|
||||
fun testFeedNotificationsDisabled() = runTest {
|
||||
val item1 = Item(title = "testAccountNotificationsDisabled", feedId = 2)
|
||||
val item2 = Item(title = "testAccountNotificationsDisabled2", feedId = 2)
|
||||
|
||||
val item2 = Item().apply {
|
||||
title = "testAccountNotificationsDisabled2"
|
||||
feedId = 2
|
||||
}
|
||||
|
||||
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)
|
||||
val syncResult = SyncResult(items = listOf(item1, item2))
|
||||
val content = syncAnalyzer.getNotificationContent(mapOf(account1 to syncResult))
|
||||
assertNull(content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTwoAccountsWithOneAccountNotificationsEnabled() {
|
||||
val item1 = Item().apply {
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled"
|
||||
feedId = 1
|
||||
remoteId = "remoteId 1"
|
||||
fun testTwoAccountsWithOneAccountNotificationsEnabled() = runTest {
|
||||
val item1 = Item(
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled",
|
||||
feedId = 1,
|
||||
remoteId = "remoteId 1",
|
||||
pubDate = LocalDateTime.now()
|
||||
}
|
||||
)
|
||||
|
||||
val item2 = Item().apply {
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled2"
|
||||
val item2 = Item(
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
|
||||
feedId = 3
|
||||
}
|
||||
)
|
||||
|
||||
val item3 = Item().apply {
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled3"
|
||||
val item3 = Item(
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
|
||||
feedId = 3
|
||||
}
|
||||
)
|
||||
|
||||
database.itemDao().insert(item1).subscribe()
|
||||
database.itemDao().insert(item1)
|
||||
|
||||
val syncResult1 = SyncResult().apply { items = listOf(item1) }
|
||||
val syncResult2 = SyncResult().apply { items = listOf(item2, item3) }
|
||||
val syncResult1 = SyncResult(items = listOf(item1))
|
||||
val syncResult2 = SyncResult(items = listOf(item2, item3))
|
||||
|
||||
val syncResults = mutableMapOf<Account, SyncResult>().apply {
|
||||
put(account1, syncResult1)
|
||||
put(account2, syncResult2)
|
||||
}
|
||||
val syncResults = mapOf(account1 to syncResult1, account2 to 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)
|
||||
assertEquals("feed 1", notifContent.title)
|
||||
assertTrue(notifContent.largeIcon != null)
|
||||
assertTrue(notifContent.item != null)
|
||||
|
||||
database.itemDao().delete(item1).subscribe()
|
||||
database.itemDao().delete(item1)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTwoAccountsWithOneFeedNotificationEnabled() {
|
||||
val item1 = Item().apply {
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled"
|
||||
feedId = 1
|
||||
remoteId = "remoteId 1"
|
||||
fun testTwoAccountsWithOneFeedNotificationEnabled() = runTest {
|
||||
val item1 = Item(
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled",
|
||||
feedId = 1,
|
||||
remoteId = "remoteId 1",
|
||||
pubDate = LocalDateTime.now()
|
||||
}
|
||||
)
|
||||
|
||||
val item2 = Item().apply {
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled2"
|
||||
val item2 = Item(
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
|
||||
feedId = 2
|
||||
}
|
||||
)
|
||||
|
||||
val item3 = Item().apply {
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled3"
|
||||
val item3 = Item(
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
|
||||
feedId = 2
|
||||
}
|
||||
)
|
||||
|
||||
database.itemDao().insert(item1).subscribe()
|
||||
database.itemDao().insert(item1)
|
||||
|
||||
val syncResult1 = SyncResult().apply { items = listOf(item1) }
|
||||
val syncResult2 = SyncResult().apply { items = listOf(item2, item3) }
|
||||
val syncResult1 = SyncResult(items = listOf(item1))
|
||||
val syncResult2 = SyncResult(items = listOf(item2, item3))
|
||||
|
||||
val syncResults = mutableMapOf<Account, SyncResult>().apply {
|
||||
put(account1, syncResult1)
|
||||
put(account2, syncResult2)
|
||||
}
|
||||
val syncResults = mapOf(account1 to syncResult1, account2 to 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)
|
||||
assertEquals("feed 1", notifContent.title)
|
||||
assertTrue(notifContent.largeIcon != null)
|
||||
assertTrue(notifContent.item != null)
|
||||
|
||||
database.itemDao().delete(item1).subscribe()
|
||||
database.itemDao().delete(item1)
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
fun testOneAccountTwoFeedsWithOneFeedNotificationEnabled() {
|
||||
val item1 = Item().apply {
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled"
|
||||
feedId = 1
|
||||
remoteId = "remoteId 1"
|
||||
fun testOneAccountTwoFeedsWithOneFeedNotificationEnabled() = runTest {
|
||||
val item1 = Item(
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled",
|
||||
feedId = 1,
|
||||
remoteId = "remoteId 1",
|
||||
pubDate = LocalDateTime.now()
|
||||
}
|
||||
)
|
||||
|
||||
val item2 = Item().apply {
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled2"
|
||||
val item2 = Item(
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled2",
|
||||
feedId = 2
|
||||
}
|
||||
)
|
||||
|
||||
val item3 = Item().apply {
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled3"
|
||||
val item3 = Item(
|
||||
title = "testTwoAccountsWithOneAccountNotificationsEnabled3",
|
||||
feedId = 2
|
||||
}
|
||||
)
|
||||
|
||||
database.itemDao().insert(item1).subscribe()
|
||||
database.itemDao().insert(item1)
|
||||
|
||||
val syncResult = SyncResult().apply { items = listOf(item1, item2, item3) }
|
||||
val notifContent = SyncResultAnalyser(context, mapOf(Pair(account1, syncResult)), database).getSyncNotifContent()
|
||||
val syncResult = SyncResult(items = listOf(item1, item2, item3))
|
||||
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)
|
||||
assertEquals("feed 1", notifContent.title)
|
||||
assertTrue(notifContent.largeIcon != null)
|
||||
assertTrue(notifContent.item != null)
|
||||
assertTrue(notifContent.accountId!! > 0)
|
||||
|
||||
database.itemDao().delete(item1).subscribe()
|
||||
database.itemDao().delete(item1)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
package com.readrops.app
|
||||
|
||||
import java.io.InputStream
|
||||
|
||||
object TestUtils {
|
||||
|
||||
fun loadResource(path: String): InputStream =
|
||||
javaClass.classLoader?.getResourceAsStream(path)!!
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 7.4 KiB |
|
@ -0,0 +1,61 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"
|
||||
xmlns:atom="http://www.w3.org/1999/xhtml">
|
||||
<channel>
|
||||
<title>Hacker News</title>
|
||||
<atom:link href="https://news.ycombinator.com/feed/" rel="self" />
|
||||
<link>https://news.ycombinator.com/</link>
|
||||
<description>Links for the intellectually curious, ranked by readers.</description>
|
||||
<item>
|
||||
<title>Africa declared free of wild polio</title>
|
||||
<link>https://www.bbc.com/news/world-africa-53887947</link>
|
||||
<pubDate>Tue, 25 Aug 2020 17:15:49 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24273602</comments>
|
||||
<author>Author 1</author>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273602">Comments</a>]]></description>
|
||||
<media:description>media description</media:description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Palantir S-1</title>
|
||||
<link>https://www.sec.gov/Archives/edgar/data/1321655/000119312520230013/d904406ds1.htm</link>
|
||||
<pubDate>Tue, 25 Aug 2020 21:03:42 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24276086</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24276086">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Openwifi: Linux mac80211 compatible full-stack 802.11/Wi-Fi design based on SDR</title>
|
||||
<link>https://github.com/open-sdr/openwifi</link>
|
||||
<pubDate>Tue, 25 Aug 2020 17:45:19 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24273919</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273919">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Syllabus for Eric's PhD Students</title>
|
||||
<link>https://docs.google.com/document/d/11D3kHElzS2HQxTwPqcaTnU5HCJ8WGE5brTXI4KLf4dM/edit</link>
|
||||
<pubDate>Tue, 25 Aug 2020 18:55:12 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24274699</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274699">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>WebBundles harmful to content blocking, security tools, and the open web</title>
|
||||
<link>https://brave.com/webbundles-harmful-to-content-blocking-security-tools-and-the-open-web/</link>
|
||||
<pubDate>Tue, 25 Aug 2020 19:18:50 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24274968</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274968">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Zappos CEO Tony Hsieh is stepping down after 21 years</title>
|
||||
<link>https://footwearnews.com/2020/business/executive-moves/zappos-ceo-tony-hsieh-steps-down-1203045974/</link>
|
||||
<pubDate>Tue, 25 Aug 2020 06:11:42 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24268522</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24268522">Comments</a>]]></description>
|
||||
</item>
|
||||
<item>
|
||||
<title>Evgeny Kuznetsov practices with Bauer stick that has hole in the blade</title>
|
||||
<link>https://russianmachineneverbreaks.com/2020/07/17/evgeny-kuznetsov-practices-with-bauer-stick-that-has-hole-in-the-blade/</link>
|
||||
<pubDate>Tue, 25 Aug 2020 19:38:09 +0000</pubDate>
|
||||
<comments>https://news.ycombinator.com/item?id=24275159</comments>
|
||||
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24275159">Comments</a>]]></description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
|
@ -1,19 +0,0 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.readrops.app">
|
||||
|
||||
<application
|
||||
android:name=".ReadropsDebugApp"
|
||||
tools:ignore="AllowBackup,GoogleAppIndexingWarning"
|
||||
tools:replace="android:name">
|
||||
|
||||
<meta-data android:name="com.niddler.icon" android:value="android"/>
|
||||
|
||||
<provider
|
||||
android:name="androidx.work.impl.WorkManagerInitializer"
|
||||
android:authorities="${applicationId}.workmanager-init"
|
||||
tools:node="remove"
|
||||
android:exported="false" />
|
||||
|
||||
</application>
|
||||
</manifest>
|
|
@ -1,55 +0,0 @@
|
|||
package com.readrops.app;
|
||||
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.work.Configuration;
|
||||
|
||||
import com.facebook.flipper.android.AndroidFlipperClient;
|
||||
import com.facebook.flipper.android.utils.FlipperUtils;
|
||||
import com.facebook.flipper.core.FlipperClient;
|
||||
import com.facebook.flipper.plugins.crashreporter.CrashReporterPlugin;
|
||||
import com.facebook.flipper.plugins.databases.DatabasesFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.inspector.DescriptorMapping;
|
||||
import com.facebook.flipper.plugins.inspector.InspectorFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.navigation.NavigationFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.network.NetworkFlipperPlugin;
|
||||
import com.facebook.flipper.plugins.sharedpreferences.SharedPreferencesFlipperPlugin;
|
||||
import com.facebook.soloader.SoLoader;
|
||||
|
||||
public class ReadropsDebugApp extends ReadropsApp implements Configuration.Provider {
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
SoLoader.init(this, false);
|
||||
|
||||
initFlipper();
|
||||
}
|
||||
|
||||
private void initFlipper() {
|
||||
if (FlipperUtils.shouldEnableFlipper(this)) {
|
||||
FlipperClient client = AndroidFlipperClient.getInstance(this);
|
||||
client.addPlugin(new InspectorFlipperPlugin(this, DescriptorMapping.withDefaults()));
|
||||
|
||||
NetworkFlipperPlugin networkPlugin = new NetworkFlipperPlugin();
|
||||
client.addPlugin(networkPlugin);
|
||||
|
||||
client.addPlugin(new DatabasesFlipperPlugin(this));
|
||||
client.addPlugin(CrashReporterPlugin.getInstance());
|
||||
client.addPlugin(NavigationFlipperPlugin.getInstance());
|
||||
client.addPlugin(new SharedPreferencesFlipperPlugin(this));
|
||||
|
||||
client.start();
|
||||
}
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Configuration getWorkManagerConfiguration() {
|
||||
return new Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.DEBUG)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +1,18 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.readrops.app">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
|
||||
|
||||
<application
|
||||
android:name=".ReadropsApp"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true"
|
||||
tools:ignore="AllowBackup,GoogleAppIndexingWarning,UnusedAttribute">
|
||||
android:theme="@style/Theme.Readrops">
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
|
@ -32,58 +24,35 @@
|
|||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<activity
|
||||
android:name=".notifications.NotificationPermissionActivity"
|
||||
android:theme="@style/AppTheme" />
|
||||
<receiver
|
||||
android:name=".sync.SyncBroadcastReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<activity
|
||||
android:name=".item.WebViewActivity"
|
||||
android:theme="@style/AppTheme.NoActionBar" />
|
||||
|
||||
<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:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/SplashTheme">
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<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">
|
||||
<intent-filter android:label="@string/add_feed">
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
|
||||
<data android:mimeType="text/plain" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".util.CrashActivity"
|
||||
android:excludeFromRecents="true"
|
||||
android:exported="false"
|
||||
android:finishOnTaskLaunch="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:theme="@style/Theme.Readrops" />
|
||||
</application>
|
||||
|
||||
</manifest>
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
@ -1,81 +1,105 @@
|
|||
package com.readrops.app
|
||||
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.chimerapps.niddler.core.AndroidNiddler
|
||||
import com.chimerapps.niddler.core.Niddler
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
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.app.account.AccountViewModel
|
||||
import com.readrops.app.addfeed.AddFeedsViewModel
|
||||
import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel
|
||||
import com.readrops.app.item.ItemViewModel
|
||||
import com.readrops.app.itemslist.MainViewModel
|
||||
import com.readrops.app.notifications.NotificationPermissionViewModel
|
||||
import com.readrops.app.repositories.FeverRepository
|
||||
import com.readrops.app.account.AccountScreenModel
|
||||
import com.readrops.app.account.credentials.AccountCredentialsScreenMode
|
||||
import com.readrops.app.account.credentials.AccountCredentialsScreenModel
|
||||
import com.readrops.app.account.selection.AccountSelectionScreenModel
|
||||
import com.readrops.app.feeds.FeedScreenModel
|
||||
import com.readrops.app.item.ItemScreenModel
|
||||
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.LocalFeedRepository
|
||||
import com.readrops.app.repositories.NextNewsRepository
|
||||
import com.readrops.app.utils.GlideApp
|
||||
import com.readrops.app.repositories.GetFoldersWithFeeds
|
||||
import com.readrops.app.repositories.LocalRSSRepository
|
||||
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.AccountType
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
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.androidx.viewmodel.dsl.viewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import org.koin.dsl.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) {
|
||||
AccountType.LOCAL -> LocalFeedRepository(get(), get(), androidContext(), account)
|
||||
AccountType.NEXTCLOUD_NEWS -> NextNewsRepository(get(parameters = { parametersOf(Credentials.toCredentials(account)) }),
|
||||
get(), androidContext(), account)
|
||||
AccountType.FRESHRSS -> FreshRSSRepository(get(parameters = { parametersOf(Credentials.toCredentials(account)) }),
|
||||
get(), androidContext(), account)
|
||||
AccountType.FEVER -> FeverRepository(get(parameters = { parametersOf(Credentials.toCredentials(account)) }),
|
||||
Dispatchers.IO, get(), get(), account)
|
||||
else -> throw IllegalArgumentException("Account type not supported")
|
||||
AccountType.LOCAL -> LocalRSSRepository(get(), get(), account)
|
||||
AccountType.FRESHRSS -> FreshRSSRepository(
|
||||
get(), account,
|
||||
get(parameters = { parametersOf(Credentials.toCredentials(account)) })
|
||||
)
|
||||
AccountType.NEXTCLOUD_NEWS -> NextcloudNewsRepository(
|
||||
get(), account,
|
||||
get(parameters = { parametersOf(Credentials.toCredentials(account)) })
|
||||
)
|
||||
else -> throw IllegalArgumentException("Unknown account type")
|
||||
}
|
||||
}
|
||||
|
||||
viewModel {
|
||||
MainViewModel(get())
|
||||
single {
|
||||
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 {
|
||||
AddFeedsViewModel(get(), get())
|
||||
single {
|
||||
PreferenceDataStoreFactory.create(
|
||||
corruptionHandler = ReplaceFileCorruptionHandler(
|
||||
produceNewData = { emptyPreferences() }
|
||||
),
|
||||
migrations = listOf(SharedPreferencesMigration(get(),"settings")),
|
||||
scope = CoroutineScope(Dispatchers.IO + SupervisorJob()),
|
||||
produceFile = { get<Context>().preferencesDataStoreFile("settings") }
|
||||
)
|
||||
}
|
||||
|
||||
viewModel {
|
||||
ItemViewModel(get())
|
||||
}
|
||||
single { DataStorePreferences(get()) }
|
||||
|
||||
viewModel {
|
||||
ManageFeedsFoldersViewModel(get())
|
||||
}
|
||||
single { Preferences(get()) }
|
||||
|
||||
viewModel {
|
||||
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() }
|
||||
}
|
||||
single { NotificationManagerCompat.from(get()) }
|
||||
}
|
|
@ -0,0 +1,130 @@
|
|||
package com.readrops.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.SystemBarStyle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBarDefaults
|
||||
import androidx.compose.material3.surfaceColorAtElevation
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import cafe.adriel.voyager.navigator.CurrentScreen
|
||||
import cafe.adriel.voyager.navigator.Navigator
|
||||
import cafe.adriel.voyager.navigator.NavigatorDisposeBehavior
|
||||
import com.readrops.app.account.selection.AccountSelectionScreen
|
||||
import com.readrops.app.account.selection.AccountSelectionScreenModel
|
||||
import com.readrops.app.home.HomeScreen
|
||||
import com.readrops.app.sync.SyncWorker
|
||||
import com.readrops.app.timelime.TimelineTab
|
||||
import com.readrops.app.util.Preferences
|
||||
import com.readrops.app.util.theme.ReadropsTheme
|
||||
import com.readrops.db.Database
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.androidx.compose.KoinAndroidContext
|
||||
import org.koin.core.annotation.KoinExperimentalAPI
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
|
||||
class MainActivity : ComponentActivity(), KoinComponent {
|
||||
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
val screenModel = get<AccountSelectionScreenModel>()
|
||||
val accountExists = screenModel.accountExists()
|
||||
|
||||
val preferences = get<Preferences>()
|
||||
|
||||
val darkFlag = resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
|
||||
val initialUseDarkTheme = runBlocking {
|
||||
useDarkTheme(preferences.theme.flow.first(), darkFlag)
|
||||
}
|
||||
|
||||
setContent {
|
||||
KoinAndroidContext {
|
||||
val useDarkTheme by preferences.theme.flow
|
||||
.map { mode -> useDarkTheme(mode, darkFlag) }
|
||||
.collectAsState(initial = initialUseDarkTheme)
|
||||
|
||||
ReadropsTheme(
|
||||
useDarkTheme = useDarkTheme
|
||||
) {
|
||||
val navigationBarElevation = NavigationBarDefaults.Elevation
|
||||
|
||||
enableEdgeToEdge(
|
||||
statusBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
|
||||
navigationBarStyle = SystemBarStyle.light(
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(navigationBarElevation)
|
||||
.toArgb(),
|
||||
MaterialTheme.colorScheme.surfaceColorAtElevation(navigationBarElevation)
|
||||
.toArgb()
|
||||
)
|
||||
)
|
||||
|
||||
Navigator(
|
||||
screen = if (accountExists) HomeScreen else AccountSelectionScreen(),
|
||||
disposeBehavior = NavigatorDisposeBehavior(
|
||||
// prevent screenModels being recreated when opening a screen from a tab
|
||||
disposeNestedNavigators = false
|
||||
)
|
||||
) {
|
||||
LaunchedEffect(Unit) {
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
CurrentScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
handleIntent(intent)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleIntent(intent: Intent) {
|
||||
when {
|
||||
intent.hasExtra(SyncWorker.ACCOUNT_ID_KEY) -> {
|
||||
val accountId = intent.getIntExtra(SyncWorker.ACCOUNT_ID_KEY, -1)
|
||||
get<Database>().accountDao()
|
||||
.updateCurrentAccount(accountId)
|
||||
|
||||
HomeScreen.openTab(TimelineTab)
|
||||
|
||||
if (intent.hasExtra(SyncWorker.ITEM_ID_KEY)) {
|
||||
val itemId = intent.getIntExtra(SyncWorker.ITEM_ID_KEY, -1)
|
||||
HomeScreen.openItemScreen(itemId)
|
||||
}
|
||||
}
|
||||
intent.action != null && intent.action == Intent.ACTION_SEND -> {
|
||||
HomeScreen.openAddFeedDialog(intent.getStringExtra(Intent.EXTRA_TEXT).orEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun useDarkTheme(mode: String, darkFlag: Int): Boolean {
|
||||
return when (mode) {
|
||||
"light" -> false
|
||||
"dark" -> true
|
||||
else -> darkFlag == Configuration.UI_MODE_NIGHT_YES
|
||||
}
|
||||
}
|
||||
}
|
|
@ -3,26 +3,38 @@ package com.readrops.app
|
|||
import android.app.Application
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import androidx.preference.PreferenceManager
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.disk.DiskCache
|
||||
import com.readrops.api.apiModule
|
||||
import com.readrops.app.utils.SharedPreferencesManager
|
||||
import com.readrops.app.util.CrashActivity
|
||||
import com.readrops.db.dbModule
|
||||
import io.reactivex.plugins.RxJavaPlugins
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
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.logger.Level
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
open class ReadropsApp : Application() {
|
||||
open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
RxJavaPlugins.setErrorHandler { e: Throwable? -> }
|
||||
|
||||
createNotificationChannels()
|
||||
PreferenceManager.setDefaultValues(this, R.xml.preferences, false)
|
||||
Thread.setDefaultUncaughtExceptionHandler { _, throwable ->
|
||||
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 {
|
||||
androidLogger(Level.ERROR)
|
||||
|
@ -31,41 +43,37 @@ open class ReadropsApp : Application() {
|
|||
modules(apiModule, dbModule, appModule)
|
||||
}
|
||||
|
||||
val theme = when (SharedPreferencesManager.readString(SharedPreferencesManager.SharedPrefKey.DARK_THEME)) {
|
||||
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
|
||||
}
|
||||
createNotificationChannels()
|
||||
}
|
||||
|
||||
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() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val feedsColorsChannel = NotificationChannel(FEEDS_COLORS_CHANNEL_ID,
|
||||
getString(R.string.feeds_colors), NotificationManager.IMPORTANCE_DEFAULT)
|
||||
feedsColorsChannel.description = getString(R.string.get_feeds_colors)
|
||||
|
||||
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)
|
||||
val syncChannel = NotificationChannel(
|
||||
SYNC_CHANNEL_ID,
|
||||
getString(R.string.auto_synchro),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
)
|
||||
syncChannel.description = getString(R.string.account_synchro)
|
||||
|
||||
val manager = getSystemService(NotificationManager::class.java)!!
|
||||
|
||||
manager.createNotificationChannel(feedsColorsChannel)
|
||||
manager.createNotificationChannel(opmlExportChannel)
|
||||
manager.createNotificationChannel(syncChannel)
|
||||
NotificationManagerCompat.from(this)
|
||||
.createNotificationChannel(syncChannel)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FEEDS_COLORS_CHANNEL_ID = "feedsColorsChannel"
|
||||
const val OPML_EXPORT_CHANNEL_ID = "opmlExportChannel"
|
||||
const val SYNC_CHANNEL_ID = "syncChannel"
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,237 @@
|
|||
package com.readrops.app.account
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.core.net.toFile
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.readrops.api.opml.OPMLParser
|
||||
import com.readrops.app.base.TabScreenModel
|
||||
import com.readrops.app.repositories.ErrorResult
|
||||
import com.readrops.app.repositories.GetFoldersWithFeeds
|
||||
import com.readrops.app.util.components.TextFieldError
|
||||
import com.readrops.app.util.components.dialog.TextFieldDialogState
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
import com.readrops.db.filters.MainFilter
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.get
|
||||
|
||||
class AccountScreenModel(
|
||||
private val database: Database,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : TabScreenModel(database) {
|
||||
|
||||
private val _closeHome = MutableStateFlow(false)
|
||||
val closeHome = _closeHome.asStateFlow()
|
||||
|
||||
private val _accountState = MutableStateFlow(AccountState())
|
||||
val accountState = _accountState.asStateFlow()
|
||||
|
||||
init {
|
||||
screenModelScope.launch(dispatcher) {
|
||||
accountEvent.collect { account ->
|
||||
_accountState.update {
|
||||
it.copy(
|
||||
account = account
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
screenModelScope.launch(dispatcher) {
|
||||
database.accountDao().selectAllAccounts()
|
||||
.map { it.filter { account -> !account.isCurrentAccount } }
|
||||
.collect { accounts ->
|
||||
_accountState.update { it.copy(accounts = accounts) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openDialog(dialog: DialogState) {
|
||||
if (dialog is DialogState.RenameAccount) {
|
||||
_accountState.update { it.copy(renameAccountState = TextFieldDialogState(value = dialog.name)) }
|
||||
}
|
||||
|
||||
_accountState.update { it.copy(dialog = dialog) }
|
||||
}
|
||||
|
||||
fun closeDialog(dialog: DialogState? = null) {
|
||||
if (dialog is DialogState.ErrorList) {
|
||||
_accountState.update { it.copy(synchronizationErrors = null) }
|
||||
} else if (dialog is DialogState.Error) {
|
||||
_accountState.update { it.copy(error = null) }
|
||||
}
|
||||
|
||||
_accountState.update { it.copy(dialog = null) }
|
||||
}
|
||||
|
||||
fun deleteAccount() {
|
||||
screenModelScope.launch(dispatcher) {
|
||||
database.accountDao()
|
||||
.delete(currentAccount!!)
|
||||
|
||||
if (_accountState.value.accounts.isNotEmpty()) {
|
||||
database.accountDao().updateCurrentAccount(_accountState.value.accounts.first().id)
|
||||
} else {
|
||||
_closeHome.update { true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun exportOPMLFile(uri: Uri, context: Context) {
|
||||
screenModelScope.launch(dispatcher) {
|
||||
val stream = context.contentResolver.openOutputStream(uri)
|
||||
if (stream == null) {
|
||||
_accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
val foldersAndFeeds =
|
||||
GetFoldersWithFeeds(database).get(
|
||||
currentAccount!!.id,
|
||||
MainFilter.ALL,
|
||||
currentAccount!!.config.useSeparateState
|
||||
).first()
|
||||
|
||||
OPMLParser.write(foldersAndFeeds, stream)
|
||||
|
||||
_accountState.update {
|
||||
it.copy(
|
||||
opmlExportSuccess = true,
|
||||
opmlExportUri = uri
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun parseOPMLFile(uri: Uri, context: Context) {
|
||||
screenModelScope.launch(dispatcher) {
|
||||
val foldersAndFeeds: Map<Folder?, List<Feed>>
|
||||
|
||||
try {
|
||||
val stream = context.contentResolver.openInputStream(uri)
|
||||
if (stream == null) {
|
||||
_accountState.update { it.copy(error = NoSuchFileException(uri.toFile())) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
foldersAndFeeds = OPMLParser.read(stream)
|
||||
} catch (e: Exception) {
|
||||
_accountState.update { it.copy(error = e) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
openDialog(
|
||||
DialogState.OPMLImport(
|
||||
currentFeed = foldersAndFeeds.values.first().first().name!!,
|
||||
feedCount = 0,
|
||||
feedMax = foldersAndFeeds.values.flatten().size
|
||||
)
|
||||
)
|
||||
|
||||
val errors = repository?.insertOPMLFoldersAndFeeds(
|
||||
foldersAndFeeds = foldersAndFeeds,
|
||||
onUpdate = { feed ->
|
||||
_accountState.update {
|
||||
val dialog = (it.dialog as DialogState.OPMLImport)
|
||||
|
||||
it.copy(
|
||||
dialog = dialog.copy(
|
||||
currentFeed = feed.name!!,
|
||||
feedCount = dialog.feedCount + 1
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
closeDialog()
|
||||
|
||||
_accountState.update {
|
||||
it.copy(synchronizationErrors = if (errors!!.isNotEmpty()) errors else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetOPMLState() =
|
||||
_accountState.update { it.copy(opmlExportUri = null, opmlExportSuccess = false) }
|
||||
|
||||
fun resetCloseHome() = _closeHome.update { false }
|
||||
|
||||
fun updateCurrentAccount(account: Account) {
|
||||
screenModelScope.launch(dispatcher) {
|
||||
database.accountDao().updateCurrentAccount(account.id)
|
||||
}
|
||||
}
|
||||
|
||||
fun createLocalAccount() {
|
||||
val context = get<Context>()
|
||||
val account = Account(
|
||||
accountName = context.getString(AccountType.LOCAL.typeName),
|
||||
accountType = AccountType.LOCAL,
|
||||
isCurrentAccount = true
|
||||
)
|
||||
|
||||
screenModelScope.launch(dispatcher) {
|
||||
database.accountDao().insert(account)
|
||||
}
|
||||
}
|
||||
|
||||
fun setAccountRenameStateName(name: String) = _accountState.update {
|
||||
it.copy(
|
||||
renameAccountState = it.renameAccountState.copy(
|
||||
value = name,
|
||||
textFieldError = null
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun renameAccount() = with(_accountState) {
|
||||
if (value.renameAccountState.value.isEmpty()) {
|
||||
update { it.copy(renameAccountState = it.renameAccountState.copy(textFieldError = TextFieldError.EmptyField)) }
|
||||
return@with
|
||||
}
|
||||
|
||||
screenModelScope.launch(dispatcher) {
|
||||
database.accountDao().renameAccount(value.account.id, value.renameAccountState.value)
|
||||
closeDialog()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class AccountState(
|
||||
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
|
||||
val dialog: DialogState? = null,
|
||||
val synchronizationErrors: ErrorResult? = null,
|
||||
val error: Exception? = null,
|
||||
val opmlExportSuccess: Boolean = false,
|
||||
val opmlExportUri: Uri? = null,
|
||||
val accounts: List<Account> = emptyList(),
|
||||
val renameAccountState: TextFieldDialogState = TextFieldDialogState()
|
||||
)
|
||||
|
||||
sealed interface DialogState {
|
||||
data object DeleteAccount : DialogState
|
||||
data object NewAccount : DialogState
|
||||
data class OPMLImport(val currentFeed: String, val feedCount: Int, val feedMax: Int) :
|
||||
DialogState
|
||||
|
||||
data class ErrorList(val errorResult: ErrorResult) : DialogState
|
||||
data class Error(val exception: Exception) : DialogState
|
||||
|
||||
data object OPMLChoice : DialogState
|
||||
|
||||
data class RenameAccount(val name: String) : DialogState
|
||||
}
|
|
@ -0,0 +1,428 @@
|
|||
package com.readrops.app.account
|
||||
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountCircle
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cafe.adriel.voyager.koin.getScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import cafe.adriel.voyager.navigator.tab.Tab
|
||||
import cafe.adriel.voyager.navigator.tab.TabOptions
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.app.R
|
||||
import com.readrops.app.account.credentials.AccountCredentialsScreen
|
||||
import com.readrops.app.account.credentials.AccountCredentialsScreenMode
|
||||
import com.readrops.app.account.selection.AccountSelectionDialog
|
||||
import com.readrops.app.account.selection.AccountSelectionScreen
|
||||
import com.readrops.app.account.selection.adaptiveIconPainterResource
|
||||
import com.readrops.app.notifications.NotificationsScreen
|
||||
import com.readrops.app.repositories.ErrorResult
|
||||
import com.readrops.app.timelime.ErrorListDialog
|
||||
import com.readrops.app.util.components.SelectableIconText
|
||||
import com.readrops.app.util.components.SelectableImageText
|
||||
import com.readrops.app.util.components.ThreeDotsMenu
|
||||
import com.readrops.app.util.components.dialog.ErrorDialog
|
||||
import com.readrops.app.util.components.dialog.TextFieldDialog
|
||||
import com.readrops.app.util.components.dialog.TwoChoicesDialog
|
||||
import com.readrops.app.util.theme.LargeSpacer
|
||||
import com.readrops.app.util.theme.MediumSpacer
|
||||
import com.readrops.app.util.theme.VeryShortSpacer
|
||||
import com.readrops.app.util.theme.spacing
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
|
||||
object AccountTab : Tab {
|
||||
|
||||
override val options: TabOptions
|
||||
@Composable
|
||||
get() = TabOptions(
|
||||
index = 3u,
|
||||
title = stringResource(R.string.account)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val context = LocalContext.current
|
||||
val screenModel = getScreenModel<AccountScreenModel>()
|
||||
|
||||
val closeHome by screenModel.closeHome.collectAsStateWithLifecycle()
|
||||
val state by screenModel.accountState.collectAsStateWithLifecycle()
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
if (closeHome) {
|
||||
navigator.replaceAll(AccountSelectionScreen())
|
||||
screenModel.resetCloseHome()
|
||||
}
|
||||
|
||||
LaunchedEffect(state.error) {
|
||||
if (state.error != null) {
|
||||
val action = snackbarHostState.showSnackbar(
|
||||
message = context.resources.getQuantityString(
|
||||
R.plurals.error_occurred,
|
||||
1
|
||||
),
|
||||
actionLabel = context.getString(R.string.details),
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
|
||||
if (action == SnackbarResult.ActionPerformed) {
|
||||
screenModel.openDialog(DialogState.Error(state.error!!))
|
||||
} else {
|
||||
screenModel.closeDialog(DialogState.Error(state.error!!))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.synchronizationErrors) {
|
||||
if (state.synchronizationErrors != null) {
|
||||
val action = snackbarHostState.showSnackbar(
|
||||
message = context.resources.getQuantityString(
|
||||
R.plurals.error_occurred,
|
||||
state.synchronizationErrors!!.size
|
||||
),
|
||||
actionLabel = context.getString(R.string.details),
|
||||
duration = SnackbarDuration.Short
|
||||
)
|
||||
|
||||
if (action == SnackbarResult.ActionPerformed) {
|
||||
screenModel.openDialog(DialogState.ErrorList(state.synchronizationErrors!!))
|
||||
} else {
|
||||
screenModel.closeDialog(DialogState.ErrorList(state.synchronizationErrors!!))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.opmlExportSuccess) {
|
||||
if (state.opmlExportSuccess) {
|
||||
val action = snackbarHostState.showSnackbar(
|
||||
message = context.getString(R.string.opml_export_success),
|
||||
actionLabel = context.resources.getString(R.string.open)
|
||||
)
|
||||
|
||||
if (action == SnackbarResult.ActionPerformed) {
|
||||
Intent().apply {
|
||||
this.action = Intent.ACTION_VIEW
|
||||
setDataAndType(state.opmlExportUri, "text/xml")
|
||||
}.also {
|
||||
context.startActivity(Intent.createChooser(it, null))
|
||||
}
|
||||
|
||||
screenModel.resetOPMLState()
|
||||
} else {
|
||||
screenModel.resetOPMLState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AccountDialogs(
|
||||
state = state,
|
||||
screenModel = screenModel
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(text = stringResource(R.string.account)) }
|
||||
)
|
||||
},
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { screenModel.openDialog(DialogState.NewAccount) }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.ic_add_account),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = MaterialTheme.spacing.mediumSpacing)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
Image(
|
||||
painter = adaptiveIconPainterResource(id = state.account.accountType!!.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = state.account.accountName!!,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
if (state.account.displayedName != null) {
|
||||
VeryShortSpacer()
|
||||
|
||||
Text(
|
||||
text = state.account.displayedName!!,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.account.isLocal) {
|
||||
ThreeDotsMenu(
|
||||
items = mapOf(1 to stringResource(id = R.string.rename_account)),
|
||||
onItemClick = {
|
||||
screenModel.openDialog(DialogState.RenameAccount(state.account.accountName!!))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LargeSpacer()
|
||||
|
||||
if (!state.account.isLocal) {
|
||||
SelectableIconText(
|
||||
icon = painterResource(id = R.drawable.ic_person),
|
||||
text = stringResource(R.string.credentials),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
|
||||
spacing = MaterialTheme.spacing.largeSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
iconSize = 24.dp,
|
||||
onClick = {
|
||||
navigator.push(
|
||||
AccountCredentialsScreen(
|
||||
state.account,
|
||||
AccountCredentialsScreenMode.EDIT_CREDENTIALS
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
SelectableIconText(
|
||||
icon = painterResource(id = R.drawable.ic_notifications),
|
||||
text = stringResource(R.string.notifications),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
|
||||
spacing = MaterialTheme.spacing.largeSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
iconSize = 24.dp,
|
||||
onClick = { navigator.push(NotificationsScreen(state.account)) }
|
||||
)
|
||||
|
||||
if (state.account.isLocal) {
|
||||
SelectableIconText(
|
||||
icon = painterResource(id = R.drawable.ic_import_export),
|
||||
text = stringResource(R.string.opml_import_export),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
|
||||
spacing = MaterialTheme.spacing.largeSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
iconSize = 24.dp,
|
||||
onClick = { screenModel.openDialog(DialogState.OPMLChoice) }
|
||||
)
|
||||
}
|
||||
|
||||
SelectableIconText(
|
||||
icon = rememberVectorPainter(image = Icons.Default.AccountCircle),
|
||||
text = stringResource(R.string.delete_account),
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal),
|
||||
spacing = MaterialTheme.spacing.largeSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
iconSize = 24.dp,
|
||||
onClick = { screenModel.openDialog(DialogState.DeleteAccount) }
|
||||
)
|
||||
|
||||
if (state.accounts.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.other_accounts),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.mediumSpacing)
|
||||
)
|
||||
|
||||
VeryShortSpacer()
|
||||
|
||||
for (account in state.accounts) {
|
||||
SelectableImageText(
|
||||
image = adaptiveIconPainterResource(id = account.accountType!!.iconRes),
|
||||
text = account.accountName!!,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
imageSize = 24.dp,
|
||||
onClick = { screenModel.updateCurrentAccount(account) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AccountDialogs(state: AccountState, screenModel: AccountScreenModel) {
|
||||
val context = LocalContext.current
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
|
||||
val opmlImportLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri?.let { screenModel.parseOPMLFile(uri, context) }
|
||||
}
|
||||
|
||||
val opmlExportLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("text/xml")) { uri ->
|
||||
uri?.let { screenModel.exportOPMLFile(uri, context) }
|
||||
}
|
||||
|
||||
when (val dialog = state.dialog) {
|
||||
is DialogState.DeleteAccount -> {
|
||||
TwoChoicesDialog(
|
||||
title = stringResource(R.string.delete_account),
|
||||
text = stringResource(R.string.delete_account_question),
|
||||
icon = rememberVectorPainter(image = Icons.Default.Delete),
|
||||
confirmText = stringResource(R.string.delete),
|
||||
dismissText = stringResource(R.string.cancel),
|
||||
onDismiss = { screenModel.closeDialog() },
|
||||
onConfirm = {
|
||||
screenModel.closeDialog()
|
||||
screenModel.deleteAccount()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.NewAccount -> {
|
||||
AccountSelectionDialog(
|
||||
onDismiss = { screenModel.closeDialog() },
|
||||
onValidate = { accountType ->
|
||||
screenModel.closeDialog()
|
||||
|
||||
if (accountType == AccountType.LOCAL) {
|
||||
screenModel.createLocalAccount()
|
||||
} else {
|
||||
val account = Account(
|
||||
accountType = accountType,
|
||||
accountName = context.resources.getString(accountType.typeName)
|
||||
)
|
||||
navigator.push(
|
||||
AccountCredentialsScreen(
|
||||
account,
|
||||
AccountCredentialsScreenMode.NEW_CREDENTIALS
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.OPMLImport -> {
|
||||
OPMLImportProgressDialog(
|
||||
currentFeed = dialog.currentFeed,
|
||||
feedCount = dialog.feedCount,
|
||||
feedMax = dialog.feedMax
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.ErrorList -> {
|
||||
ErrorListDialog(
|
||||
errorResult = dialog.errorResult as ErrorResult, // cast needed by assembleRelease
|
||||
onDismiss = { screenModel.closeDialog(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.Error -> {
|
||||
ErrorDialog(
|
||||
exception = dialog.exception,
|
||||
onDismiss = { screenModel.closeDialog(dialog) }
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.OPMLChoice -> {
|
||||
OPMLChoiceDialog(
|
||||
onChoice = {
|
||||
if (it == OPML.IMPORT) {
|
||||
opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray())
|
||||
} else {
|
||||
opmlExportLauncher.launch("subscriptions.opml")
|
||||
}
|
||||
|
||||
screenModel.closeDialog()
|
||||
},
|
||||
onDismiss = { screenModel.closeDialog() }
|
||||
)
|
||||
}
|
||||
|
||||
is DialogState.RenameAccount -> {
|
||||
TextFieldDialog(
|
||||
title = stringResource(id = R.string.rename_account),
|
||||
icon = painterResource(id = R.drawable.ic_person),
|
||||
label = stringResource(id = R.string.name),
|
||||
state = state.renameAccountState,
|
||||
onValueChange = { screenModel.setAccountRenameStateName(it) },
|
||||
onValidate = { screenModel.renameAccount() },
|
||||
onDismiss = { screenModel.closeDialog() }
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,197 +0,0 @@
|
|||
package com.readrops.app.account;
|
||||
|
||||
import static com.readrops.app.utils.OPMLHelper.OPEN_OPML_FILE_REQUEST;
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_TYPE;
|
||||
import static com.readrops.app.utils.ReadropsKeys.FROM_MAIN_ACTIVITY;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.util.Log;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.widget.LinearLayout;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.recyclerview.widget.DividerItemDecoration;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import com.afollestad.materialdialogs.MaterialDialog;
|
||||
import com.readrops.app.R;
|
||||
import com.readrops.app.databinding.ActivityAccountTypeListBinding;
|
||||
import com.readrops.app.itemslist.MainActivity;
|
||||
import com.readrops.app.utils.OPMLHelper;
|
||||
import com.readrops.app.utils.Utils;
|
||||
import com.readrops.db.entities.account.Account;
|
||||
import com.readrops.db.entities.account.AccountType;
|
||||
|
||||
import org.koin.android.compat.ViewModelCompat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.observers.DisposableCompletableObserver;
|
||||
import io.reactivex.observers.DisposableSingleObserver;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
public class AccountTypeListActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = AccountTypeListActivity.class.getSimpleName();
|
||||
|
||||
private ActivityAccountTypeListBinding binding;
|
||||
private AccountTypeListAdapter adapter;
|
||||
private AccountViewModel viewModel;
|
||||
|
||||
private boolean fromMainActivity;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
binding = ActivityAccountTypeListBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
viewModel = ViewModelCompat.getViewModel(this, AccountViewModel.class);
|
||||
|
||||
setTitle(R.string.new_account);
|
||||
|
||||
binding.accountTypeRecyclerview.setLayoutManager(new LinearLayoutManager(this));
|
||||
binding.accountTypeRecyclerview.addItemDecoration(new DividerItemDecoration(this, LinearLayout.VERTICAL));
|
||||
|
||||
fromMainActivity = getIntent().getBooleanExtra(FROM_MAIN_ACTIVITY, false);
|
||||
|
||||
if (fromMainActivity)
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
adapter = new AccountTypeListAdapter(accountType -> {
|
||||
if (accountType != AccountType.LOCAL) {
|
||||
Intent intent = new Intent(getApplicationContext(), AddAccountActivity.class);
|
||||
|
||||
if (fromMainActivity)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_FORWARD_RESULT);
|
||||
|
||||
intent.putExtra(ACCOUNT_TYPE, (Parcelable) accountType);
|
||||
|
||||
startActivity(intent);
|
||||
finish();
|
||||
} else {
|
||||
Account account = new Account(null, getString(AccountType.LOCAL.getTypeName()), AccountType.LOCAL);
|
||||
account.setCurrentAccount(true);
|
||||
|
||||
viewModel.insert(account)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(new DisposableSingleObserver<Long>() {
|
||||
@Override
|
||||
public void onSuccess(Long id) {
|
||||
account.setId(id.intValue());
|
||||
goToNextActivity(account);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable e) {
|
||||
Log.e(TAG, e.getMessage());
|
||||
Utils.showSnackbar(binding.accountTypeListRoot, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
binding.accountTypeRecyclerview.setAdapter(adapter);
|
||||
adapter.setAccountTypes(getData());
|
||||
}
|
||||
|
||||
private List<AccountType> getData() {
|
||||
List<AccountType> accountTypes = new ArrayList<>();
|
||||
|
||||
accountTypes.add(AccountType.LOCAL);
|
||||
accountTypes.add(AccountType.NEXTCLOUD_NEWS);
|
||||
accountTypes.add(AccountType.FRESHRSS);
|
||||
accountTypes.add(AccountType.FEVER);
|
||||
|
||||
return accountTypes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
public void openOPMLFile(View view) {
|
||||
OPMLHelper.openFileIntent(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) {
|
||||
if (requestCode == OPEN_OPML_FILE_REQUEST && resultCode == RESULT_OK && data != null) {
|
||||
Uri uri = data.getData();
|
||||
|
||||
MaterialDialog dialog = new MaterialDialog.Builder(this)
|
||||
.title(R.string.opml_processing)
|
||||
.content(R.string.operation_takes_time)
|
||||
.progress(true, 100)
|
||||
.cancelable(false)
|
||||
.show();
|
||||
|
||||
parseOPMLFile(uri, dialog);
|
||||
}
|
||||
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
}
|
||||
|
||||
private void parseOPMLFile(Uri uri, MaterialDialog dialog) {
|
||||
Account account = new Account(null, getString(AccountType.LOCAL.getTypeName()), AccountType.LOCAL);
|
||||
account.setCurrentAccount(true);
|
||||
|
||||
viewModel.insert(account)
|
||||
.flatMapCompletable(id -> {
|
||||
account.setId(id.intValue());
|
||||
viewModel.setAccount(account);
|
||||
|
||||
return viewModel.parseOPMLFile(uri, this);
|
||||
})
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(new DisposableCompletableObserver() {
|
||||
@Override
|
||||
public void onComplete() {
|
||||
dialog.dismiss();
|
||||
goToNextActivity(account);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable e) {
|
||||
Log.e(TAG, e.getMessage());
|
||||
|
||||
dialog.dismiss();
|
||||
Utils.showSnackbar(binding.accountTypeListRoot, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void goToNextActivity(Account account) {
|
||||
if (fromMainActivity) {
|
||||
Intent intent = new Intent();
|
||||
intent.putExtra(ACCOUNT, account);
|
||||
setResult(RESULT_OK, intent);
|
||||
} else {
|
||||
Intent intent = new Intent(getApplicationContext(), MainActivity.class);
|
||||
intent.putExtra(ACCOUNT, account);
|
||||
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
package com.readrops.app.account;
|
||||
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.readrops.app.databinding.AccountTypeItemBinding;
|
||||
import com.readrops.db.entities.account.AccountType;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class AccountTypeListAdapter extends RecyclerView.Adapter<AccountTypeListAdapter.AccountTypeViewHolder> {
|
||||
|
||||
private List<AccountType> accountTypes;
|
||||
private OnItemClickListener listener;
|
||||
|
||||
public AccountTypeListAdapter(OnItemClickListener listener) {
|
||||
this.listener = listener;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public AccountTypeViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
|
||||
AccountTypeItemBinding binding = AccountTypeItemBinding.inflate(LayoutInflater.from(parent.getContext()),
|
||||
parent, false);
|
||||
|
||||
return new AccountTypeViewHolder(binding);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(@NonNull AccountTypeViewHolder holder, int position) {
|
||||
AccountType accountType = accountTypes.get(position);
|
||||
|
||||
holder.binding.accountTypeName.setText(accountType.getTypeName());
|
||||
holder.binding.accountTypeLogo.setImageResource(accountType.getIconRes());
|
||||
|
||||
holder.binding.getRoot().setOnClickListener(v -> listener.onItemClick(accountType));
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return accountTypes.size();
|
||||
}
|
||||
|
||||
public void setAccountTypes(List<AccountType> accountTypes) {
|
||||
this.accountTypes = accountTypes;
|
||||
notifyDataSetChanged();
|
||||
}
|
||||
|
||||
public interface OnItemClickListener {
|
||||
void onItemClick(AccountType accountType);
|
||||
}
|
||||
|
||||
public class AccountTypeViewHolder extends RecyclerView.ViewHolder {
|
||||
|
||||
private AccountTypeItemBinding binding;
|
||||
|
||||
public AccountTypeViewHolder(AccountTypeItemBinding binding) {
|
||||
super(binding.getRoot());
|
||||
|
||||
this.binding = binding;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,70 +0,0 @@
|
|||
package com.readrops.app.account;
|
||||
|
||||
import android.content.Context;
|
||||
import android.net.Uri;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.ViewModel;
|
||||
|
||||
import com.readrops.api.opml.OPMLParser;
|
||||
import com.readrops.app.repositories.ARepository;
|
||||
import com.readrops.db.Database;
|
||||
import com.readrops.db.entities.Feed;
|
||||
import com.readrops.db.entities.Folder;
|
||||
import com.readrops.db.entities.account.Account;
|
||||
|
||||
import org.koin.core.parameter.ParametersHolderKt;
|
||||
import org.koin.java.KoinJavaComponent;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.reactivex.Completable;
|
||||
import io.reactivex.Single;
|
||||
|
||||
public class AccountViewModel extends ViewModel {
|
||||
|
||||
private ARepository repository;
|
||||
private final Database database;
|
||||
|
||||
public AccountViewModel(@NonNull Database database) {
|
||||
this.database = database;
|
||||
}
|
||||
|
||||
public void setAccount(Account account) {
|
||||
repository = KoinJavaComponent.get(ARepository.class, null,
|
||||
() -> ParametersHolderKt.parametersOf(account));
|
||||
}
|
||||
|
||||
public Completable login(Account account, boolean insert) {
|
||||
setAccount(account);
|
||||
return repository.login(account, insert);
|
||||
}
|
||||
|
||||
public Single<Long> insert(Account account) {
|
||||
return database.accountDao().insert(account);
|
||||
}
|
||||
|
||||
public Completable update(Account account) {
|
||||
return database.accountDao().update(account);
|
||||
}
|
||||
|
||||
public Completable delete(Account account) {
|
||||
return database.accountDao().delete(account);
|
||||
}
|
||||
|
||||
public Single<Integer> getAccountCount() {
|
||||
return database.accountDao().getAccountCount();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public Single<Map<Folder, List<Feed>>> getFoldersWithFeeds() {
|
||||
return repository.getFoldersWithFeeds();
|
||||
}
|
||||
|
||||
public Completable parseOPMLFile(Uri uri, Context context) throws FileNotFoundException {
|
||||
return OPMLParser.read(context.getContentResolver().openInputStream(uri))
|
||||
.flatMapCompletable(foldersAndFeeds -> repository.insertOPMLFoldersAndFeeds(foldersAndFeeds));
|
||||
}
|
||||
}
|
|
@ -1,239 +0,0 @@
|
|||
package com.readrops.app.account;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
import android.util.Log;
|
||||
import android.util.Patterns;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
import com.readrops.app.R;
|
||||
import com.readrops.app.databinding.ActivityAddAccountBinding;
|
||||
import com.readrops.app.itemslist.MainActivity;
|
||||
import com.readrops.app.utils.SharedPreferencesManager;
|
||||
import com.readrops.app.utils.Utils;
|
||||
import com.readrops.db.entities.account.Account;
|
||||
import com.readrops.db.entities.account.AccountType;
|
||||
|
||||
import io.reactivex.CompletableObserver;
|
||||
import io.reactivex.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.disposables.Disposable;
|
||||
import io.reactivex.schedulers.Schedulers;
|
||||
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
|
||||
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT_TYPE;
|
||||
import static com.readrops.app.utils.ReadropsKeys.EDIT_ACCOUNT;
|
||||
|
||||
import org.koin.android.compat.ViewModelCompat;
|
||||
|
||||
public class AddAccountActivity extends AppCompatActivity {
|
||||
|
||||
private static final String TAG = AddAccountActivity.class.getSimpleName();
|
||||
|
||||
private ActivityAddAccountBinding binding;
|
||||
private AccountViewModel viewModel;
|
||||
|
||||
private AccountType accountType;
|
||||
private boolean forwardResult, editAccount;
|
||||
|
||||
private Account accountToEdit;
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
binding = ActivityAddAccountBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
viewModel = ViewModelCompat.getViewModel(this, AccountViewModel.class);
|
||||
|
||||
accountType = getIntent().getParcelableExtra(ACCOUNT_TYPE);
|
||||
|
||||
int flag = getIntent().getFlags();
|
||||
forwardResult = flag == Intent.FLAG_ACTIVITY_FORWARD_RESULT;
|
||||
|
||||
accountToEdit = getIntent().getParcelableExtra(EDIT_ACCOUNT);
|
||||
|
||||
if (forwardResult || accountToEdit != null)
|
||||
getSupportActionBar().setDisplayHomeAsUpEnabled(true);
|
||||
|
||||
if (accountToEdit != null) {
|
||||
editAccount = true;
|
||||
fillFields();
|
||||
} else {
|
||||
binding.providerImage.setImageResource(accountType.getIconRes());
|
||||
binding.providerName.setText(accountType.getTypeName());
|
||||
binding.addAccountName.setText(accountType.getTypeName());
|
||||
|
||||
if (accountType == AccountType.FRESHRSS) {
|
||||
binding.addAccountPasswordLayout.setHelperText(getString(R.string.password_helper));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void createAccount(View view) {
|
||||
if (fieldsAreValid()) {
|
||||
String url = binding.addAccountUrl.getText().toString().trim();
|
||||
String name = binding.addAccountName.getText().toString().trim();
|
||||
String login = binding.addAccountLogin.getText().toString().trim();
|
||||
String password = binding.addAccountPassword.getText().toString().trim();
|
||||
|
||||
if (!(url.toLowerCase().contains(Utils.HTTP_PREFIX) || url.toLowerCase().contains(Utils.HTTPS_PREFIX))) {
|
||||
url = Utils.HTTPS_PREFIX + url;
|
||||
}
|
||||
|
||||
if (editAccount) {
|
||||
accountToEdit.setUrl(url);
|
||||
accountToEdit.setAccountName(name);
|
||||
accountToEdit.setLogin(login);
|
||||
accountToEdit.setPassword(password);
|
||||
|
||||
updateAccount();
|
||||
} else {
|
||||
Account account = new Account(url, name, accountType);
|
||||
account.setLogin(login);
|
||||
account.setPassword(password);
|
||||
|
||||
viewModel.login(account, true)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(new CompletableObserver() {
|
||||
|
||||
@Override
|
||||
public void onSubscribe(Disposable d) {
|
||||
binding.addAccountLoading.setVisibility(View.VISIBLE);
|
||||
binding.addAccountValidate.setEnabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
saveLoginPassword(account);
|
||||
|
||||
if (forwardResult) {
|
||||
Intent intent = new Intent();
|
||||
intent.putExtra(ACCOUNT, account);
|
||||
setResult(RESULT_OK, intent);
|
||||
} else {
|
||||
Intent intent = new Intent(getApplicationContext(), MainActivity.class);
|
||||
intent.putExtra(ACCOUNT, account);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable e) {
|
||||
Log.d(TAG, e.getMessage());
|
||||
binding.addAccountLoading.setVisibility(View.GONE);
|
||||
binding.addAccountValidate.setEnabled(true);
|
||||
|
||||
Utils.showSnackbar(binding.addAccountRoot, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private boolean fieldsAreValid() {
|
||||
boolean valid = true;
|
||||
|
||||
if (binding.addAccountUrl.getText().toString().trim().isEmpty()) {
|
||||
binding.addAccountUrl.setError(getString(R.string.empty_field));
|
||||
valid = false;
|
||||
} else if (!Patterns.WEB_URL.matcher(binding.addAccountUrl.getText().toString().trim()).matches()) {
|
||||
binding.addAccountUrl.setError(getString(R.string.wrong_url));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (binding.addAccountName.getText().toString().trim().isEmpty()) {
|
||||
binding.addAccountName.setError(getString(R.string.empty_field));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (binding.addAccountLogin.getText().toString().trim().isEmpty()) {
|
||||
binding.addAccountLogin.setError(getString(R.string.empty_field));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if (binding.addAccountPassword.getText().toString().trim().isEmpty()) {
|
||||
binding.addAccountPassword.setError(getString(R.string.empty_field));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
private void saveLoginPassword(Account account) {
|
||||
SharedPreferencesManager.writeValue(account.getLoginKey(), account.getLogin());
|
||||
SharedPreferencesManager.writeValue(account.getPasswordKey(), account.getPassword());
|
||||
|
||||
account.setLogin(null);
|
||||
account.setPassword(null);
|
||||
}
|
||||
|
||||
private void fillFields() {
|
||||
binding.providerImage.setImageResource(accountToEdit.getAccountType().getIconRes());
|
||||
binding.providerName.setText(accountToEdit.getAccountType().getTypeName());
|
||||
|
||||
binding.addAccountUrl.setText(accountToEdit.getUrl());
|
||||
binding.addAccountName.setText(accountToEdit.getAccountName());
|
||||
binding.addAccountLogin.setText(SharedPreferencesManager.readString(accountToEdit.getLoginKey()));
|
||||
binding.addAccountPassword.setText(SharedPreferencesManager.readString(accountToEdit.getPasswordKey()));
|
||||
}
|
||||
|
||||
private void updateAccount() {
|
||||
viewModel.login(accountToEdit, false)
|
||||
.doOnError(throwable -> Utils.showSnackbar(binding.addAccountRoot, throwable.getMessage()))
|
||||
.doAfterTerminate(() -> saveLoginPassword(accountToEdit))
|
||||
.andThen(viewModel.update(accountToEdit))
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(new CompletableObserver() {
|
||||
@Override
|
||||
public void onSubscribe(Disposable d) {
|
||||
binding.addAccountLoading.setVisibility(View.VISIBLE);
|
||||
binding.addAccountValidate.setEnabled(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable e) {
|
||||
binding.addAccountLoading.setVisibility(View.GONE);
|
||||
binding.addAccountValidate.setEnabled(true);
|
||||
|
||||
Utils.showSnackbar(binding.addAccountRoot, e.getMessage());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onOptionsItemSelected(MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case android.R.id.home:
|
||||
finish();
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onOptionsItemSelected(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onKeyUp(int keyCode, KeyEvent event) {
|
||||
switch (keyCode) {
|
||||
case KeyEvent.KEYCODE_ENTER:
|
||||
createAccount(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.onKeyUp(keyCode, event);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,56 @@
|
|||
package com.readrops.app.account
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.readrops.app.R
|
||||
import com.readrops.app.util.components.dialog.BaseDialog
|
||||
import com.readrops.app.util.theme.spacing
|
||||
|
||||
enum class OPML {
|
||||
IMPORT,
|
||||
EXPORT
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun OPMLChoiceDialog(
|
||||
onChoice: (OPML) -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
BaseDialog(
|
||||
title = stringResource(id = R.string.opml_import_export),
|
||||
icon = painterResource(id = R.drawable.ic_import_export),
|
||||
onDismiss = onDismiss
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onChoice(OPML.IMPORT) }
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.opml_import),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
|
||||
)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onChoice(OPML.EXPORT) }
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.opml_export),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(MaterialTheme.spacing.shortSpacing)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,27 @@
|
|||
package com.readrops.app.account
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.readrops.app.R
|
||||
import com.readrops.app.util.components.dialog.BaseDialog
|
||||
import com.readrops.app.util.components.RefreshIndicator
|
||||
|
||||
@Composable
|
||||
fun OPMLImportProgressDialog(
|
||||
currentFeed: String,
|
||||
feedCount: Int,
|
||||
feedMax: Int,
|
||||
) {
|
||||
BaseDialog(
|
||||
title = stringResource(id = R.string.opml_import),
|
||||
icon = painterResource(R.drawable.ic_import_export),
|
||||
onDismiss = {}
|
||||
) {
|
||||
RefreshIndicator(
|
||||
currentFeed = currentFeed,
|
||||
feedCount = feedCount,
|
||||
feedMax = feedMax
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,237 @@
|
|||
package com.readrops.app.account.credentials
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cafe.adriel.voyager.koin.getScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.readrops.app.R
|
||||
import com.readrops.app.home.HomeScreen
|
||||
import com.readrops.app.util.ErrorMessage
|
||||
import com.readrops.app.util.components.AndroidScreen
|
||||
import com.readrops.app.util.theme.MediumSpacer
|
||||
import com.readrops.app.util.theme.ShortSpacer
|
||||
import com.readrops.app.util.theme.spacing
|
||||
import com.readrops.db.entities.account.Account
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
enum class AccountCredentialsScreenMode {
|
||||
NEW_CREDENTIALS,
|
||||
EDIT_CREDENTIALS
|
||||
}
|
||||
|
||||
class AccountCredentialsScreen(
|
||||
private val account: Account,
|
||||
private val mode: AccountCredentialsScreenMode
|
||||
) : AndroidScreen() {
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val screenModel =
|
||||
getScreenModel<AccountCredentialsScreenModel>(parameters = { parametersOf(account, mode) })
|
||||
|
||||
val state by screenModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
if (state.exitScreen) {
|
||||
if (mode == AccountCredentialsScreenMode.NEW_CREDENTIALS) {
|
||||
navigator.replaceAll(HomeScreen)
|
||||
} else {
|
||||
navigator.pop()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {
|
||||
Text(
|
||||
text = if (mode == AccountCredentialsScreenMode.EDIT_CREDENTIALS)
|
||||
stringResource(id = R.string.credentials)
|
||||
else
|
||||
stringResource(id = R.string.new_account)
|
||||
)
|
||||
},
|
||||
navigationIcon = {
|
||||
IconButton(
|
||||
onClick = { navigator.pop() }
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Default.ArrowBack,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.imePadding()
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.fillMaxSize()
|
||||
.padding(MaterialTheme.spacing.mediumSpacing)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
Image(
|
||||
painter = painterResource(id = account.accountType!!.iconRes),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = account.accountType!!.typeName),
|
||||
style = MaterialTheme.typography.headlineMedium
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.name,
|
||||
onValueChange = { screenModel.onEvent(Event.NameEvent(it)) },
|
||||
label = { Text(text = stringResource(id = R.string.account_name)) },
|
||||
singleLine = true,
|
||||
isError = state.isNameError,
|
||||
supportingText = { Text(text = state.nameError?.errorText().orEmpty()) },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.url,
|
||||
onValueChange = { screenModel.onEvent(Event.URLEvent(it)) },
|
||||
label = { Text(text = stringResource(id = R.string.account_url)) },
|
||||
singleLine = true,
|
||||
isError = state.isUrlError,
|
||||
supportingText = { Text(text = state.urlError?.errorText().orEmpty()) },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.login,
|
||||
onValueChange = { screenModel.onEvent(Event.LoginEvent(it)) },
|
||||
label = { Text(text = stringResource(id = R.string.login)) },
|
||||
singleLine = true,
|
||||
isError = state.isLoginError,
|
||||
supportingText = { Text(text = state.loginError?.errorText().orEmpty()) },
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
OutlinedTextField(
|
||||
value = state.password,
|
||||
onValueChange = { screenModel.onEvent(Event.PasswordEvent(it)) },
|
||||
label = { Text(text = stringResource(id = R.string.password)) },
|
||||
trailingIcon = {
|
||||
IconButton(
|
||||
onClick = { screenModel.setPasswordVisibility(!state.isPasswordVisible) }
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(
|
||||
id = if (state.isPasswordVisible) {
|
||||
R.drawable.ic_visible_off
|
||||
} else R.drawable.ic_visible
|
||||
),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
singleLine = true,
|
||||
visualTransformation = if (state.isPasswordVisible)
|
||||
VisualTransformation.None
|
||||
else
|
||||
PasswordVisualTransformation(),
|
||||
isError = state.isPasswordError,
|
||||
supportingText = {
|
||||
Text(
|
||||
text = state.passwordError?.errorText().orEmpty()
|
||||
)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Button(
|
||||
onClick = { screenModel.login() },
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
if (state.isLoginOnGoing) {
|
||||
CircularProgressIndicator(
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
strokeWidth = 2.dp,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
} else {
|
||||
Text(text = stringResource(id = R.string.validate))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loginException != null) {
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = ErrorMessage.get(state.loginException!!, LocalContext.current),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,161 @@
|
|||
package com.readrops.app.account.credentials
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.util.Patterns
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.readrops.app.repositories.BaseRepository
|
||||
import com.readrops.app.util.components.TextFieldError
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.account.Account
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
class AccountCredentialsScreenModel(
|
||||
private val account: Account,
|
||||
private val mode: AccountCredentialsScreenMode,
|
||||
private val database: Database,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : StateScreenModel<AccountCredentialsState>(AccountCredentialsState()), KoinComponent {
|
||||
|
||||
init {
|
||||
if (mode == AccountCredentialsScreenMode.EDIT_CREDENTIALS) {
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
name = account.accountName!!,
|
||||
url = account.url!!,
|
||||
login = account.login!!,
|
||||
password = account.password!!
|
||||
)
|
||||
}
|
||||
} else {
|
||||
mutableState.update { it.copy(name = account.accountName!!) }
|
||||
}
|
||||
}
|
||||
|
||||
fun onEvent(event: Event): Unit = with(mutableState) {
|
||||
when (event) {
|
||||
is Event.LoginEvent -> update { it.copy(login = event.value, loginError = null) }
|
||||
is Event.NameEvent -> update { it.copy(name = event.value, nameError = null) }
|
||||
is Event.PasswordEvent -> update { it.copy(password = event.value, passwordError = null) }
|
||||
is Event.URLEvent -> update { it.copy(url = event.value, urlError = null) }
|
||||
}
|
||||
}
|
||||
|
||||
fun setPasswordVisibility(isVisible: Boolean) {
|
||||
mutableState.update { it.copy(isPasswordVisible = isVisible) }
|
||||
}
|
||||
|
||||
fun login() {
|
||||
if (validateFields()) {
|
||||
mutableState.update { it.copy(isLoginOnGoing = true) }
|
||||
|
||||
with(state.value) {
|
||||
val newAccount = account.copy(
|
||||
url = url,
|
||||
accountName = name,
|
||||
login = login,
|
||||
password = password,
|
||||
accountType = account.accountType,
|
||||
isCurrentAccount = true
|
||||
)
|
||||
|
||||
val repository = get<BaseRepository> { parametersOf(newAccount) }
|
||||
|
||||
screenModelScope.launch(dispatcher) {
|
||||
try {
|
||||
repository.login(newAccount)
|
||||
} catch (e: Exception) {
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
loginException = e,
|
||||
isLoginOnGoing = false
|
||||
)
|
||||
}
|
||||
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (mode == AccountCredentialsScreenMode.NEW_CREDENTIALS) {
|
||||
newAccount.id = database.accountDao().insert(newAccount).toInt()
|
||||
|
||||
get<SharedPreferences>().edit()
|
||||
.putString(newAccount.loginKey, newAccount.login)
|
||||
.putString(newAccount.passwordKey, newAccount.password)
|
||||
.apply()
|
||||
} else {
|
||||
database.accountDao().update(newAccount)
|
||||
}
|
||||
|
||||
mutableState.update { it.copy(exitScreen = true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun validateFields(): Boolean = with(mutableState.value) {
|
||||
var validate = true
|
||||
|
||||
if (url.isEmpty()) {
|
||||
mutableState.update { it.copy(urlError = TextFieldError.EmptyField) }
|
||||
validate = false
|
||||
}
|
||||
|
||||
if (name.isEmpty()) {
|
||||
mutableState.update { it.copy(nameError = TextFieldError.EmptyField) }
|
||||
validate = false
|
||||
}
|
||||
|
||||
if (login.isEmpty()) {
|
||||
mutableState.update { it.copy(loginError = TextFieldError.EmptyField) }
|
||||
validate = false
|
||||
}
|
||||
|
||||
if (password.isEmpty()) {
|
||||
mutableState.update { it.copy(passwordError = TextFieldError.EmptyField) }
|
||||
validate = false
|
||||
}
|
||||
|
||||
if (url.isNotEmpty() && !Patterns.WEB_URL.matcher(url).matches()) {
|
||||
mutableState.update { it.copy(urlError = TextFieldError.BadUrl) }
|
||||
validate = false
|
||||
}
|
||||
|
||||
return validate
|
||||
}
|
||||
}
|
||||
|
||||
data class AccountCredentialsState(
|
||||
val url: String = "https://",
|
||||
val urlError: TextFieldError? = null,
|
||||
val name: String = "",
|
||||
val nameError: TextFieldError? = null,
|
||||
val login: String = "",
|
||||
val loginError: TextFieldError? = null,
|
||||
val password: String = "",
|
||||
val passwordError: TextFieldError? = null,
|
||||
val isPasswordVisible: Boolean = false,
|
||||
val isLoginOnGoing: Boolean = false,
|
||||
val exitScreen: Boolean = false,
|
||||
val loginException: Exception? = null
|
||||
) {
|
||||
val isUrlError = urlError != null
|
||||
|
||||
val isNameError = nameError != null
|
||||
|
||||
val isLoginError = loginError != null
|
||||
|
||||
val isPasswordError = passwordError != null
|
||||
}
|
||||
|
||||
sealed class Event(val value: String) {
|
||||
class URLEvent(value: String) : Event(value)
|
||||
class NameEvent(value: String) : Event(value)
|
||||
class LoginEvent(value: String) : Event(value)
|
||||
class PasswordEvent(value: String) : Event(value)
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package com.readrops.app.account.selection
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.readrops.app.R
|
||||
import com.readrops.app.util.components.dialog.BaseDialog
|
||||
import com.readrops.app.util.components.SelectableImageText
|
||||
import com.readrops.app.util.theme.spacing
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
|
||||
@Composable
|
||||
fun AccountSelectionDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onValidate: (AccountType) -> Unit,
|
||||
) {
|
||||
BaseDialog(
|
||||
title = stringResource(R.string.new_account),
|
||||
icon = painterResource(id = R.drawable.ic_add_account),
|
||||
onDismiss = onDismiss
|
||||
) {
|
||||
AccountType.entries.forEach { type ->
|
||||
SelectableImageText(
|
||||
image = adaptiveIconPainterResource(id = type.iconRes),
|
||||
text = stringResource(id = type.typeName),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
padding = MaterialTheme.spacing.shortSpacing,
|
||||
imageSize = 36.dp,
|
||||
onClick = { onValidate(type) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,240 @@
|
|||
package com.readrops.app.account.selection
|
||||
|
||||
import android.graphics.drawable.AdaptiveIconDrawable
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.graphics.painter.BitmapPainter
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.res.ResourcesCompat
|
||||
import androidx.core.graphics.drawable.toBitmap
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import cafe.adriel.voyager.koin.getScreenModel
|
||||
import cafe.adriel.voyager.navigator.LocalNavigator
|
||||
import cafe.adriel.voyager.navigator.currentOrThrow
|
||||
import com.readrops.api.utils.ApiUtils
|
||||
import com.readrops.app.BuildConfig
|
||||
import com.readrops.app.R
|
||||
import com.readrops.app.account.OPMLImportProgressDialog
|
||||
import com.readrops.app.account.credentials.AccountCredentialsScreen
|
||||
import com.readrops.app.account.credentials.AccountCredentialsScreenMode
|
||||
import com.readrops.app.home.HomeScreen
|
||||
import com.readrops.app.util.ErrorMessage
|
||||
import com.readrops.app.util.components.AndroidScreen
|
||||
import com.readrops.app.util.components.SelectableImageText
|
||||
import com.readrops.app.util.theme.LargeSpacer
|
||||
import com.readrops.app.util.theme.MediumSpacer
|
||||
import com.readrops.app.util.theme.ShortSpacer
|
||||
import com.readrops.app.util.theme.spacing
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
|
||||
class AccountSelectionScreen : AndroidScreen() {
|
||||
|
||||
@Composable
|
||||
override fun Content() {
|
||||
val navigator = LocalNavigator.currentOrThrow
|
||||
val context = LocalContext.current
|
||||
|
||||
val screenModel = getScreenModel<AccountSelectionScreenModel>()
|
||||
val state by screenModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
val opmlImportLauncher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
|
||||
uri?.let { screenModel.parseOPMLFile(uri, context) }
|
||||
}
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
if (state.showOPMLImportDialog) {
|
||||
OPMLImportProgressDialog(
|
||||
currentFeed = state.currentFeed,
|
||||
feedCount = state.feedCount,
|
||||
feedMax = state.feedMax
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(state.exception) {
|
||||
if (state.exception != null) {
|
||||
snackbarHostState.showSnackbar(ErrorMessage.get(state.exception!!, context))
|
||||
screenModel.resetException()
|
||||
}
|
||||
}
|
||||
|
||||
when (state.navState) {
|
||||
is NavState.GoToHomeScreen -> {
|
||||
// using replace makes the app crash due to a screen key conflict
|
||||
navigator.replaceAll(HomeScreen)
|
||||
}
|
||||
|
||||
is NavState.GoToAccountCredentialsScreen -> {
|
||||
val accountType =
|
||||
(state.navState as NavState.GoToAccountCredentialsScreen).accountType
|
||||
val account = Account(
|
||||
accountType = accountType,
|
||||
accountName = stringResource(id = accountType.typeName)
|
||||
)
|
||||
|
||||
navigator.push(
|
||||
AccountCredentialsScreen(account, AccountCredentialsScreenMode.NEW_CREDENTIALS)
|
||||
)
|
||||
screenModel.resetNavState()
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) }
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f)
|
||||
.padding(MaterialTheme.spacing.mediumSpacing)
|
||||
) {
|
||||
Image(
|
||||
painter = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp)
|
||||
)
|
||||
|
||||
ShortSpacer()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.app_name),
|
||||
style = MaterialTheme.typography.headlineLarge,
|
||||
)
|
||||
|
||||
LargeSpacer()
|
||||
|
||||
Card {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(id = R.string.choose_account),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(top = MaterialTheme.spacing.mediumSpacing)
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Text(
|
||||
text = stringResource(id = R.string.local),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
|
||||
)
|
||||
|
||||
SelectableImageText(
|
||||
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
|
||||
text = stringResource(id = AccountType.LOCAL.typeName),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
imageSize = 24.dp,
|
||||
onClick = { screenModel.createAccount(AccountType.LOCAL) }
|
||||
)
|
||||
|
||||
SelectableImageText(
|
||||
image = adaptiveIconPainterResource(id = R.mipmap.ic_launcher),
|
||||
text = stringResource(id = R.string.opml_import),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
imageSize = 24.dp,
|
||||
onClick = { opmlImportLauncher.launch(ApiUtils.OPML_MIMETYPES.toTypedArray()) }
|
||||
)
|
||||
|
||||
MediumSpacer()
|
||||
|
||||
Text(
|
||||
text = stringResource(R.string.external),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.padding(start = MaterialTheme.spacing.mediumSpacing)
|
||||
)
|
||||
|
||||
AccountType.entries.filter { it != AccountType.LOCAL }
|
||||
.forEach { accountType ->
|
||||
SelectableImageText(
|
||||
image = adaptiveIconPainterResource(id = accountType.iconRes),
|
||||
text = stringResource(id = accountType.typeName),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
imageSize = 24.dp,
|
||||
spacing = MaterialTheme.spacing.mediumSpacing,
|
||||
padding = MaterialTheme.spacing.mediumSpacing,
|
||||
onClick = { screenModel.createAccount(accountType) }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "v${BuildConfig.VERSION_NAME}",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterHorizontally)
|
||||
.padding(bottom = MaterialTheme.spacing.veryShortSpacing)
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// from https://gist.github.com/tkuenneth/ddf598663f041dc79960cda503d14448
|
||||
@Composable
|
||||
fun adaptiveIconPainterResource(@DrawableRes id: Int): Painter {
|
||||
val res = LocalContext.current.resources
|
||||
val theme = LocalContext.current.theme
|
||||
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
// Android O supports adaptive icons, try loading this first (even though this is least likely to be the format).
|
||||
val adaptiveIcon = ResourcesCompat.getDrawable(res, id, theme) as? AdaptiveIconDrawable
|
||||
if (adaptiveIcon != null) {
|
||||
BitmapPainter(adaptiveIcon.toBitmap().asImageBitmap())
|
||||
} else {
|
||||
// We couldn't load the drawable as an Adaptive Icon, just use painterResource
|
||||
painterResource(id)
|
||||
}
|
||||
} else {
|
||||
// We're not on Android O or later, just use painterResource
|
||||
painterResource(id)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,133 @@
|
|||
package com.readrops.app.account.selection
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.core.net.toFile
|
||||
import cafe.adriel.voyager.core.model.StateScreenModel
|
||||
import cafe.adriel.voyager.core.model.screenModelScope
|
||||
import com.readrops.api.opml.OPMLParser
|
||||
import com.readrops.app.repositories.BaseRepository
|
||||
import com.readrops.db.Database
|
||||
import com.readrops.db.entities.Feed
|
||||
import com.readrops.db.entities.Folder
|
||||
import com.readrops.db.entities.account.Account
|
||||
import com.readrops.db.entities.account.AccountType
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.parameter.parametersOf
|
||||
|
||||
class AccountSelectionScreenModel(
|
||||
private val database: Database,
|
||||
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
|
||||
) : StateScreenModel<AccountSelectionState>(AccountSelectionState()), KoinComponent {
|
||||
|
||||
fun accountExists(): Boolean {
|
||||
val accountCount = runBlocking {
|
||||
database.accountDao().selectAccountCount()
|
||||
}
|
||||
|
||||
return accountCount > 0
|
||||
}
|
||||
|
||||
fun createAccount(accountType: AccountType) {
|
||||
if (accountType == AccountType.LOCAL) {
|
||||
screenModelScope.launch(dispatcher) {
|
||||
createLocalAccount()
|
||||
mutableState.update { it.copy(navState = NavState.GoToHomeScreen) }
|
||||
}
|
||||
} else {
|
||||
mutableState.update {
|
||||
it.copy(navState = NavState.GoToAccountCredentialsScreen(accountType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetNavState() {
|
||||
mutableState.update { it.copy(navState = NavState.Idle) }
|
||||
}
|
||||
|
||||
private suspend fun createLocalAccount(): Account {
|
||||
val context = get<Context>()
|
||||
val account = Account(
|
||||
url = null,
|
||||
accountName = context.getString(AccountType.LOCAL.typeName),
|
||||
accountType = AccountType.LOCAL,
|
||||
isCurrentAccount = true
|
||||
)
|
||||
|
||||
account.id = database.accountDao().insert(account).toInt()
|
||||
return account
|
||||
}
|
||||
|
||||
fun parseOPMLFile(uri: Uri, context: Context) {
|
||||
screenModelScope.launch(dispatcher) {
|
||||
val foldersAndFeeds: Map<Folder?, List<Feed>>
|
||||
|
||||
try {
|
||||
val stream = context.contentResolver.openInputStream(uri)
|
||||
if (stream == null) {
|
||||
mutableState.update { it.copy(exception = NoSuchFileException(uri.toFile())) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
foldersAndFeeds = OPMLParser.read(stream)
|
||||
} catch (e: Exception) {
|
||||
mutableState.update { it.copy(exception = e) }
|
||||
return@launch
|
||||
}
|
||||
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
showOPMLImportDialog = true,
|
||||
currentFeed = foldersAndFeeds.values.first().first().name!!,
|
||||
feedCount = 0,
|
||||
feedMax = foldersAndFeeds.values.flatten().size
|
||||
)
|
||||
}
|
||||
|
||||
val account = createLocalAccount()
|
||||
val repository = get<BaseRepository> { parametersOf(account) }
|
||||
|
||||
repository.insertOPMLFoldersAndFeeds(
|
||||
foldersAndFeeds = foldersAndFeeds,
|
||||
onUpdate = { feed ->
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
currentFeed = feed.name!!,
|
||||
feedCount = it.feedCount + 1
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
mutableState.update {
|
||||
it.copy(
|
||||
showOPMLImportDialog = false,
|
||||
navState = NavState.GoToHomeScreen
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetException() = mutableState.update { it.copy(exception = null) }
|
||||
}
|
||||
|
||||
data class AccountSelectionState(
|
||||
val showOPMLImportDialog: Boolean = false,
|
||||
val navState: NavState = NavState.Idle,
|
||||
val exception: Exception? = null,
|
||||
val currentFeed: String = "",
|
||||
val feedCount: Int = 0,
|
||||
val feedMax: Int = 0
|
||||
)
|
||||
|
||||
sealed class NavState {
|
||||
data object Idle : NavState()
|
||||
data object GoToHomeScreen : NavState()
|
||||
class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState()
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
package com.readrops.app.addfeed;
|
||||
|
||||
import android.content.Context;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.readrops.app.R;
|
||||
import com.readrops.db.entities.account.Account;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class AccountArrayAdapter extends ArrayAdapter<Account> {
|
||||
|
||||
public AccountArrayAdapter(@NonNull Context context, @NonNull List<Account> objects) {
|
||||
super(context, 0, objects);
|
||||
}
|
||||
|
||||
@Override
|
||||
public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
|
||||
return createItemView(position, convertView, parent);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
|
||||
return createItemView(position, convertView, parent);
|
||||
}
|
||||
|
||||
private View createItemView(int position, View convertView, ViewGroup parent) {
|
||||
if (convertView == null) {
|
||||
convertView = LayoutInflater.from(getContext()).inflate(R.layout.account_type_item, parent, false);
|
||||
}
|
||||
|
||||
Account account = getItem(position);
|
||||
|
||||
ImageView accountIcon = convertView.findViewById(R.id.account_type_logo);
|
||||
TextView accountName = convertView.findViewById(R.id.account_type_name);
|
||||
|
||||
accountIcon.setImageResource(account.getAccountType().getIconRes());
|
||||
accountName.setText(account.getAccountType().getTypeName());
|
||||
|
||||
return convertView;
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue