Merge branch 'develop' into pr/Alkarex/163

This commit is contained in:
Alexandre Alapetite 2024-02-22 21:53:19 +01:00
commit d5d8b16148
No known key found for this signature in database
GPG Key ID: A24378C38E812B23
150 changed files with 8895 additions and 110 deletions

View File

@ -15,17 +15,18 @@ jobs:
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: Android Emulator Runner
uses: ReactiveCircus/android-emulator-runner@v2.20.0
uses: ReactiveCircus/android-emulator-runner@v2.28.0
with:
api-level: 29
script: ./gradlew clean build connectedCheck jacocoFullReport
- 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

View File

@ -12,9 +12,6 @@ android {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
lintOptions {
abortOnError false
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/androidTest/assets".toString())
@ -33,12 +30,17 @@ android {
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = '17'
freeCompilerArgs = ["-Xstring-concat=inline"]
}
lint {
abortOnError false
}
namespace 'com.readrops.api'
}
dependencies {
@ -58,7 +60,7 @@ dependencies {
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'
api 'com.squareup.okhttp3:okhttp:4.9.1'
implementation('com.squareup.retrofit2:retrofit:2.9.0') {
exclude group: 'com.squareup.okhttp3', module: 'okhttp3'

View File

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

View File

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

View File

@ -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.freshrss.FreshRSSDataSource
@ -12,6 +11,7 @@ import com.readrops.api.services.nextcloudnews.adapters.NextNewsFeedsAdapter
import com.readrops.api.services.nextcloudnews.adapters.NextNewsFoldersAdapter
import com.readrops.api.services.nextcloudnews.adapters.NextNewsItemsAdapter
import com.readrops.api.utils.AuthInterceptor
import com.readrops.api.utils.ErrorInterceptor
import com.readrops.db.entities.Item
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
@ -30,12 +30,15 @@ val apiModule = module {
.callTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.HOURS)
.addInterceptor(get<AuthInterceptor>())
.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
.addInterceptor(get<ErrorInterceptor>())
//.addInterceptor(NiddlerOkHttpInterceptor(get(), "niddler"))
.build()
}
single { AuthInterceptor() }
single { ErrorInterceptor() }
single { LocalRSSDataSource(get()) }
//region freshrss

View File

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

View File

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

View File

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

View File

@ -0,0 +1,19 @@
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)
if (!response.isSuccessful) {
throw HttpException(response)
}
return response
}
}

View File

@ -0,0 +1,88 @@
package com.readrops.api.utils
import android.nfc.FormatException
import com.readrops.api.localfeed.LocalRSSHelper
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
data class ParsingResult(
val url: String,
val label: String?,
)
object HtmlParser {
suspend fun getFaviconLink(url: String, client: OkHttpClient): String? {
val document = getHTMLHeadFromUrl(url, client)
val elements = document.select("link")
for (element in elements) {
if (element.attributes()["rel"].lowercase().contains("icon")) {
return element.absUrl("href")
}
}
return null
}
suspend fun getFeedLink(url: String, client: OkHttpClient): List<ParsingResult> {
val results = mutableListOf<ParsingResult>()
val document = getHTMLHeadFromUrl(url, client)
val elements = document.select("link")
for (element in elements) {
val type = element.attributes()["type"]
if (LocalRSSHelper.isRSSType(type)) {
results += ParsingResult(
url = element.absUrl("href"),
label = element.attributes()["title"]
)
}
}
return results
}
private fun getHTMLHeadFromUrl(url: String, client: OkHttpClient): Document {
client.newCall(Request.Builder().url(url).build()).execute().use { response ->
if (response.header(ApiUtils.CONTENT_TYPE_HEADER)!!.contains(ApiUtils.HTML_CONTENT_TYPE)
) {
val body = response.body!!.source()
val stringBuilder = StringBuilder()
var collectionStarted = false
while (!body.exhausted()) {
val currentLine = body.readUtf8LineStrict()
when {
currentLine.contains("<head>") -> {
stringBuilder.append(currentLine)
collectionStarted = true
}
currentLine.contains("</head>") -> {
stringBuilder.append(currentLine)
break
}
collectionStarted -> {
stringBuilder.append(currentLine)
}
}
}
if (!stringBuilder.contains("<head>") || !stringBuilder.contains("</head>"))
throw FormatException("Failed to get HTML head")
body.close()
return Jsoup.parse(stringBuilder.toString(), url)
} else {
throw FormatException("The response is not a html file")
}
}
}
}

View File

@ -0,0 +1,13 @@
package com.readrops.api.utils.exceptions
import okhttp3.Response
class HttpException(val response: Response) : Exception() {
val code: Int
get() = response.code
override val message: String
get() = "HTTP " + response.code + " " + response.message
}

View File

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

View File

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

View File

@ -0,0 +1,39 @@
package com.readrops.api.utils
import com.readrops.api.utils.exceptions.HttpException
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.After
import org.junit.Before
import org.junit.Test
import java.net.HttpURLConnection
class ErrorInterceptorTest {
private val interceptor = ErrorInterceptor()
private val server = MockWebServer()
private lateinit var client: OkHttpClient
@Before
fun before() {
client = OkHttpClient.Builder()
.addInterceptor(interceptor)
.build()
server.start(8080)
}
@After
fun tearDown() {
server.close()
}
@Test(expected = HttpException::class)
fun interceptorTest() {
server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NOT_FOUND))
client.newCall(Request.Builder().url(server.url("/url")).build()).execute()
//val request = server.takeRequest()
}
}

View File

@ -0,0 +1,124 @@
package com.readrops.api.utils
import android.nfc.FormatException
import com.readrops.api.TestUtils
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.Rule
import org.junit.Test
import org.koin.dsl.module
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import java.net.HttpURLConnection
import java.util.concurrent.TimeUnit
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class HtmlParserTest : KoinTest {
private val mockServer = MockWebServer()
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(module {
single {
OkHttpClient.Builder()
.callTimeout(1, TimeUnit.MINUTES)
.readTimeout(1, TimeUnit.HOURS)
.build()
}
})
}
@Test
fun before() {
mockServer.start()
}
@Test
fun after() {
mockServer.shutdown()
}
@Test
fun getFeedLinkTest() {
val stream = TestUtils.loadResource("utils/file.html")
mockServer.enqueue(
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
.setBody(Buffer().readFrom(stream))
)
runBlocking {
val result =
HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
assertTrue { result.size == 1 }
assertTrue { result.first().url.endsWith("/rss") }
assertEquals("RSS", result.first().label)
}
}
@Test(expected = FormatException::class)
fun getFeedLinkWithoutHeadTest() {
val stream = TestUtils.loadResource("utils/file_without_head.html")
mockServer.enqueue(
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
.setBody(Buffer().readFrom(stream))
)
runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) }
}
@Test(expected = FormatException::class)
fun getFeedLinkNoHtmlFileTest() {
mockServer.enqueue(
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "application/rss+xml"))
runBlocking { HtmlParser.getFeedLink(mockServer.url("/rss").toString(), koinTestRule.koin.get()) }
}
@Test
fun getFaviconLinkTest() {
val stream = TestUtils.loadResource("utils/file.html")
mockServer.enqueue(
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
.setBody(Buffer().readFrom(stream))
)
runBlocking {
val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
assertTrue { result!!.contains("favicon.ico") }
}
}
@Test
fun getFaviconLinkWithoutHeadTest() {
val stream = TestUtils.loadResource("utils/file_without_icon.html")
mockServer.enqueue(
MockResponse().setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, ApiUtils.HTML_CONTENT_TYPE)
.setBody(Buffer().readFrom(stream))
)
runBlocking {
val result = HtmlParser.getFaviconLink(mockServer.url("/rss").toString(), koinTestRule.koin.get())
assertNull(result)
}
}
}

View File

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

View File

@ -0,0 +1 @@
PMvYZHrnC57cyPLzxFvQmJEGN6KvNmkHCmHQPKG5eznWMXriq13H1nQZg

View File

@ -0,0 +1,601 @@
<html lang="en" op="news">
<head>
<meta name="referrer" content="origin">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="news.css?t8fsBYOw2Gz0ODjGokUo">
<link rel="shortcut icon" href="favicon.ico">
<link rel="alternate" type="application/rss+xml" title="RSS" href="rss">
<title>Hacker News</title>
</head>
<body>
<center>
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
<tr>
<td bgcolor="#ff6600">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
<tr>
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
</td>
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
<a href="login?goto=news">login</a>
</span>
</td>
</tr>
</table>
</td>
</tr>
<tr id="pagespace" title="" style="height:10px"></tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
<tr class='athing' id='36826210'>
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826210'href='vote?id=36826210&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&amp;goto=news">hide</a> | <a href="item?id=36826210">3&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36813688'>
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36813688'href='vote?id=36813688&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&amp;goto=news">hide</a> | <a href="item?id=36813688">1&nbsp;comment</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36797650'>
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36797650'href='vote?id=36797650&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&amp;goto=news">hide</a> | <a href="item?id=36797650">26&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36827034'>
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36827034'href='vote?id=36827034&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&amp;goto=news">hide</a> | <a href="item?id=36827034">1&nbsp;comment</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823565'>
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823565'href='vote?id=36823565&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple&#x27;s interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&amp;goto=news">hide</a> | <a href="item?id=36823565">20&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823605'>
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823605'href='vote?id=36823605&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&amp;goto=news">hide</a> | <a href="item?id=36823605">73&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36826177'>
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826177'href='vote?id=36826177&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&amp;goto=news">hide</a> | <a href="item?id=36826177">10&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824595'>
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824595'href='vote?id=36824595&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&amp;goto=news">hide</a> | <a href="item?id=36824595">23&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825992'>
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825992'href='vote?id=36825992&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you&#x27;ve ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&amp;goto=news">hide</a> | <a href="item?id=36825992">3&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825345'>
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825345'href='vote?id=36825345&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&amp;goto=news">hide</a> | <a href="item?id=36825345">1&nbsp;comment</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823516'>
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823516'href='vote?id=36823516&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&amp;goto=news">hide</a> | <a href="item?id=36823516">66&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824450'>
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824450'href='vote?id=36824450&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&amp;goto=news">hide</a> | <a href="item?id=36824450">93&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825481'>
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825481'href='vote?id=36825481&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&amp;goto=news">hide</a> | <a href="item?id=36825481">15&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823375'>
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823375'href='vote?id=36823375&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&amp;goto=news">hide</a> | <a href="item?id=36823375">22&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823524'>
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823524'href='vote?id=36823524&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&amp;goto=news">hide</a> | <a href="item?id=36823524">73&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824607'>
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824607'href='vote?id=36824607&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&amp;goto=news">hide</a> | <a href="item?id=36824607">43&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825913'>
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825913'href='vote?id=36825913&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&amp;goto=news">hide</a> | <a href="item?id=36825913">24&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36797471'>
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36797471'href='vote?id=36797471&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&amp;goto=news">hide</a> | <a href="item?id=36797471">73&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825204'>
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825204'href='vote?id=36825204&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&amp;goto=news">hide</a> | <a href="item?id=36825204">72&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36822880'>
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36822880'href='vote?id=36822880&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&amp;goto=news">hide</a> | <a href="item?id=36822880">58&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36803767'>
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36803767'href='vote?id=36803767&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&amp;goto=news">hide</a> | <a href="item?id=36803767">18&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824330'>
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824330'href='vote?id=36824330&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&amp;goto=news">hide</a> | <a href="item?id=36824330">11&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36826111'>
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826111'href='vote?id=36826111&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&amp;goto=news">hide</a> | <a href="item?id=36826111">121&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36784114'>
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36784114'href='vote?id=36784114&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&amp;goto=news">hide</a> | <a href="item?id=36784114">75&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36808566'>
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36808566'href='vote?id=36808566&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusais Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&amp;goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823723'>
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823723'href='vote?id=36823723&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&amp;goto=news">hide</a> | <a href="item?id=36823723">107&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824856'>
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824856'href='vote?id=36824856&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&amp;goto=news">hide</a> | <a href="item?id=36824856">55&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36822530'>
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36822530'href='vote?id=36822530&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&amp;goto=news">hide</a> | <a href="item?id=36822530">24&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36783937'>
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36783937'href='vote?id=36783937&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&amp;goto=news">hide</a> | <a href="item?id=36783937">57&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36826664'>
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826664'href='vote?id=36826664&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&amp;goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class="morespace" style="height:10px"></tr>
<tr>
<td colspan="2"></td>
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<img src="s.gif" height="10" width="0">
<table width="100%" cellspacing="0" cellpadding="1">
<tr>
<td bgcolor="#ff6600"></td>
</tr>
</table>
<br>
<center>
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
</center>
</td>
</tr>
</table>
</center>
</body>
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
</html>

View File

@ -0,0 +1,593 @@
<html lang="en" op="news">
<body>
<center>
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
<tr>
<td bgcolor="#ff6600">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
<tr>
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
</td>
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
<a href="login?goto=news">login</a>
</span>
</td>
</tr>
</table>
</td>
</tr>
<tr id="pagespace" title="" style="height:10px"></tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
<tr class='athing' id='36826210'>
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826210'href='vote?id=36826210&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&amp;goto=news">hide</a> | <a href="item?id=36826210">3&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36813688'>
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36813688'href='vote?id=36813688&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&amp;goto=news">hide</a> | <a href="item?id=36813688">1&nbsp;comment</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36797650'>
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36797650'href='vote?id=36797650&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&amp;goto=news">hide</a> | <a href="item?id=36797650">26&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36827034'>
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36827034'href='vote?id=36827034&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&amp;goto=news">hide</a> | <a href="item?id=36827034">1&nbsp;comment</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823565'>
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823565'href='vote?id=36823565&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple&#x27;s interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&amp;goto=news">hide</a> | <a href="item?id=36823565">20&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823605'>
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823605'href='vote?id=36823605&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&amp;goto=news">hide</a> | <a href="item?id=36823605">73&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36826177'>
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826177'href='vote?id=36826177&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&amp;goto=news">hide</a> | <a href="item?id=36826177">10&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824595'>
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824595'href='vote?id=36824595&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&amp;goto=news">hide</a> | <a href="item?id=36824595">23&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825992'>
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825992'href='vote?id=36825992&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you&#x27;ve ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&amp;goto=news">hide</a> | <a href="item?id=36825992">3&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825345'>
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825345'href='vote?id=36825345&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&amp;goto=news">hide</a> | <a href="item?id=36825345">1&nbsp;comment</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823516'>
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823516'href='vote?id=36823516&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&amp;goto=news">hide</a> | <a href="item?id=36823516">66&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824450'>
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824450'href='vote?id=36824450&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&amp;goto=news">hide</a> | <a href="item?id=36824450">93&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825481'>
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825481'href='vote?id=36825481&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&amp;goto=news">hide</a> | <a href="item?id=36825481">15&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823375'>
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823375'href='vote?id=36823375&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&amp;goto=news">hide</a> | <a href="item?id=36823375">22&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823524'>
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823524'href='vote?id=36823524&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&amp;goto=news">hide</a> | <a href="item?id=36823524">73&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824607'>
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824607'href='vote?id=36824607&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&amp;goto=news">hide</a> | <a href="item?id=36824607">43&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825913'>
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825913'href='vote?id=36825913&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&amp;goto=news">hide</a> | <a href="item?id=36825913">24&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36797471'>
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36797471'href='vote?id=36797471&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&amp;goto=news">hide</a> | <a href="item?id=36797471">73&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825204'>
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825204'href='vote?id=36825204&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&amp;goto=news">hide</a> | <a href="item?id=36825204">72&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36822880'>
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36822880'href='vote?id=36822880&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&amp;goto=news">hide</a> | <a href="item?id=36822880">58&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36803767'>
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36803767'href='vote?id=36803767&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&amp;goto=news">hide</a> | <a href="item?id=36803767">18&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824330'>
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824330'href='vote?id=36824330&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&amp;goto=news">hide</a> | <a href="item?id=36824330">11&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36826111'>
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826111'href='vote?id=36826111&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&amp;goto=news">hide</a> | <a href="item?id=36826111">121&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36784114'>
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36784114'href='vote?id=36784114&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&amp;goto=news">hide</a> | <a href="item?id=36784114">75&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36808566'>
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36808566'href='vote?id=36808566&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusais Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&amp;goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823723'>
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823723'href='vote?id=36823723&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&amp;goto=news">hide</a> | <a href="item?id=36823723">107&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824856'>
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824856'href='vote?id=36824856&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&amp;goto=news">hide</a> | <a href="item?id=36824856">55&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36822530'>
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36822530'href='vote?id=36822530&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&amp;goto=news">hide</a> | <a href="item?id=36822530">24&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36783937'>
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36783937'href='vote?id=36783937&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&amp;goto=news">hide</a> | <a href="item?id=36783937">57&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36826664'>
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826664'href='vote?id=36826664&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&amp;goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class="morespace" style="height:10px"></tr>
<tr>
<td colspan="2"></td>
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<img src="s.gif" height="10" width="0">
<table width="100%" cellspacing="0" cellpadding="1">
<tr>
<td bgcolor="#ff6600"></td>
</tr>
</table>
<br>
<center>
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
</center>
</td>
</tr>
</table>
</center>
</body>
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
</html>

View File

@ -0,0 +1,600 @@
<html lang="en" op="news">
<head>
<meta name="referrer" content="origin">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="news.css?t8fsBYOw2Gz0ODjGokUo">
<link rel="alternate" type="application/rss+xml" title="RSS" href="rss">
<title>Hacker News</title>
</head>
<body>
<center>
<table id="hnmain" border="0" cellpadding="0" cellspacing="0" width="85%" bgcolor="#f6f6ef">
<tr>
<td bgcolor="#ff6600">
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="padding:2px">
<tr>
<td style="width:18px;padding-right:4px"><a href="https://news.ycombinator.com"><img src="y18.svg" width="18" height="18" style="border:1px white solid; display:block"></a></td>
<td style="line-height:12pt; height:10px;"><span class="pagetop"><b class="hnname"><a href="news">Hacker News</a></b>
<a href="newest">new</a> | <a href="front">past</a> | <a href="newcomments">comments</a> | <a href="ask">ask</a> | <a href="show">show</a> | <a href="jobs">jobs</a> | <a href="submit">submit</a> </span>
</td>
<td style="text-align:right;padding-right:4px;"><span class="pagetop">
<a href="login?goto=news">login</a>
</span>
</td>
</tr>
</table>
</td>
</tr>
<tr id="pagespace" title="" style="height:10px"></tr>
<tr>
<td>
<table border="0" cellpadding="0" cellspacing="0">
<tr class='athing' id='36826210'>
<td align="right" valign="top" class="title"><span class="rank">1.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826210'href='vote?id=36826210&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.lesswrong.com/posts/vfRpzyGsikujm9ujj/a-brief-history-of-computers" rel="noreferrer">A Brief History of Computers</a><span class="sitebit comhead"> (<a href="from?site=lesswrong.com"><span class="sitestr">lesswrong.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826210">31 points</span> by <a href="user?id=zdw" class="hnuser">zdw</a> <span class="age" title="2023-07-22T13:50:28"><a href="item?id=36826210">1 hour ago</a></span> <span id="unv_36826210"></span> | <a href="hide?id=36826210&amp;goto=news">hide</a> | <a href="item?id=36826210">3&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36813688'>
<td align="right" valign="top" class="title"><span class="rank">2.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36813688'href='vote?id=36813688&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.csmonitor.com/1994/0513/13082.html" rel="noreferrer">Consumer Software Is Expected to Be Next Fast-Growing Segment (1994)</a><span class="sitebit comhead"> (<a href="from?site=csmonitor.com"><span class="sitestr">csmonitor.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36813688">9 points</span> by <a href="user?id=1970-01-01" class="hnuser">1970-01-01</a> <span class="age" title="2023-07-21T13:46:46"><a href="item?id=36813688">1 hour ago</a></span> <span id="unv_36813688"></span> | <a href="hide?id=36813688&amp;goto=news">hide</a> | <a href="item?id=36813688">1&nbsp;comment</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36797650'>
<td align="right" valign="top" class="title"><span class="rank">3.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36797650'href='vote?id=36797650&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://en.wikipedia.org/wiki/MSX-DOS" rel="noreferrer">MSX-DOS</a><span class="sitebit comhead"> (<a href="from?site=wikipedia.org"><span class="sitestr">wikipedia.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36797650">82 points</span> by <a href="user?id=pavlov" class="hnuser">pavlov</a> <span class="age" title="2023-07-20T07:08:01"><a href="item?id=36797650">6 hours ago</a></span> <span id="unv_36797650"></span> | <a href="hide?id=36797650&amp;goto=news">hide</a> | <a href="item?id=36797650">26&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36827034'>
<td align="right" valign="top" class="title"><span class="rank">4.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36827034'href='vote?id=36827034&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.nytimes.com/interactive/2023/07/21/nyregion/nyc-developers-private-owned-public-spaces.html" rel="noreferrer">New Yorkers Got Broken Promises. Developers Got 20M Sq. Ft</a><span class="sitebit comhead"> (<a href="from?site=nytimes.com"><span class="sitestr">nytimes.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36827034">12 points</span> by <a href="user?id=asnyder" class="hnuser">asnyder</a> <span class="age" title="2023-07-22T15:27:37"><a href="item?id=36827034">20 minutes ago</a></span> <span id="unv_36827034"></span> | <a href="hide?id=36827034&amp;goto=news">hide</a> | <a href="item?id=36827034">1&nbsp;comment</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823565'>
<td align="right" valign="top" class="title"><span class="rank">5.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823565'href='vote?id=36823565&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="http://oldvcr.blogspot.com/2023/07/apples-interactive-television-box.html" rel="noreferrer">Apple&#x27;s interactive television box: Hacking the set top box System 7.1 in ROM</a><span class="sitebit comhead"> (<a href="from?site=oldvcr.blogspot.com"><span class="sitestr">oldvcr.blogspot.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823565">160 points</span> by <a href="user?id=todsacerdoti" class="hnuser">todsacerdoti</a> <span class="age" title="2023-07-22T05:30:35"><a href="item?id=36823565">10 hours ago</a></span> <span id="unv_36823565"></span> | <a href="hide?id=36823565&amp;goto=news">hide</a> | <a href="item?id=36823565">20&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823605'>
<td align="right" valign="top" class="title"><span class="rank">6.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823605'href='vote?id=36823605&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://cpu.land/" rel="noreferrer">Putting the “You” in CPU</a><span class="sitebit comhead"> (<a href="from?site=cpu.land"><span class="sitestr">cpu.land</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823605">187 points</span> by <a href="user?id=uneekname" class="hnuser">uneekname</a> <span class="age" title="2023-07-22T05:38:53"><a href="item?id=36823605">10 hours ago</a></span> <span id="unv_36823605"></span> | <a href="hide?id=36823605&amp;goto=news">hide</a> | <a href="item?id=36823605">73&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36826177'>
<td align="right" valign="top" class="title"><span class="rank">7.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826177'href='vote?id=36826177&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC3028942/" rel="noreferrer">Botulinum toxin: Bioweapon and magic drug</a><span class="sitebit comhead"> (<a href="from?site=nih.gov"><span class="sitestr">nih.gov</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826177">12 points</span> by <a href="user?id=redbell" class="hnuser">redbell</a> <span class="age" title="2023-07-22T13:46:08"><a href="item?id=36826177">2 hours ago</a></span> <span id="unv_36826177"></span> | <a href="hide?id=36826177&amp;goto=news">hide</a> | <a href="item?id=36826177">10&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824595'>
<td align="right" valign="top" class="title"><span class="rank">8.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824595'href='vote?id=36824595&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://github.com/underpig1/octos">Octos HTML live wallpaper engine</a><span class="sitebit comhead"> (<a href="from?site=github.com/underpig1"><span class="sitestr">github.com/underpig1</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824595">85 points</span> by <a href="user?id=underpig1" class="hnuser">underpig1</a> <span class="age" title="2023-07-22T08:56:14"><a href="item?id=36824595">6 hours ago</a></span> <span id="unv_36824595"></span> | <a href="hide?id=36824595&amp;goto=news">hide</a> | <a href="item?id=36824595">23&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825992'>
<td align="right" valign="top" class="title"><span class="rank">9.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825992'href='vote?id=36825992&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.shuttle.rs/blog/2022/06/30/error-handling">More than you&#x27;ve ever wanted to know about errors in Rust</a><span class="sitebit comhead"> (<a href="from?site=shuttle.rs"><span class="sitestr">shuttle.rs</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825992">13 points</span> by <a href="user?id=asymmetric" class="hnuser">asymmetric</a> <span class="age" title="2023-07-22T13:20:02"><a href="item?id=36825992">2 hours ago</a></span> <span id="unv_36825992"></span> | <a href="hide?id=36825992&amp;goto=news">hide</a> | <a href="item?id=36825992">3&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825345'>
<td align="right" valign="top" class="title"><span class="rank">10.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825345'href='vote?id=36825345&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://ferd.ca/embrace-complexity-tighten-your-feedback-loops.html" rel="noreferrer">Embrace Complexity; Tighten Your Feedback Loops</a><span class="sitebit comhead"> (<a href="from?site=ferd.ca"><span class="sitestr">ferd.ca</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825345">27 points</span> by <a href="user?id=lutzh" class="hnuser">lutzh</a> <span class="age" title="2023-07-22T11:30:46"><a href="item?id=36825345">4 hours ago</a></span> <span id="unv_36825345"></span> | <a href="hide?id=36825345&amp;goto=news">hide</a> | <a href="item?id=36825345">1&nbsp;comment</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823516'>
<td align="right" valign="top" class="title"><span class="rank">11.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823516'href='vote?id=36823516&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://miparnisariblog.wordpress.com/2023/03/29/aws-networking-concepts/" rel="noreferrer">AWS networking concepts in a diagram</a><span class="sitebit comhead"> (<a href="from?site=miparnisariblog.wordpress.com"><span class="sitestr">miparnisariblog.wordpress.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823516">171 points</span> by <a href="user?id=mparnisari" class="hnuser">mparnisari</a> <span class="age" title="2023-07-22T05:18:37"><a href="item?id=36823516">10 hours ago</a></span> <span id="unv_36823516"></span> | <a href="hide?id=36823516&amp;goto=news">hide</a> | <a href="item?id=36823516">66&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824450'>
<td align="right" valign="top" class="title"><span class="rank">12.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824450'href='vote?id=36824450&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://plane.so" rel="noreferrer">Plane Open-source Jira alternative</a><span class="sitebit comhead"> (<a href="from?site=plane.so"><span class="sitestr">plane.so</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824450">240 points</span> by <a href="user?id=prhrb" class="hnuser">prhrb</a> <span class="age" title="2023-07-22T08:21:40"><a href="item?id=36824450">7 hours ago</a></span> <span id="unv_36824450"></span> | <a href="hide?id=36824450&amp;goto=news">hide</a> | <a href="item?id=36824450">93&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825481'>
<td align="right" valign="top" class="title"><span class="rank">13.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825481'href='vote?id=36825481&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.frontiersin.org/articles/10.3389/fnsys.2017.00093/full" rel="noreferrer">Neurotechnology: Current Developments and Ethical Issues</a><span class="sitebit comhead"> (<a href="from?site=frontiersin.org"><span class="sitestr">frontiersin.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825481">28 points</span> by <a href="user?id=Quinzel" class="hnuser">Quinzel</a> <span class="age" title="2023-07-22T11:57:41"><a href="item?id=36825481">3 hours ago</a></span> <span id="unv_36825481"></span> | <a href="hide?id=36825481&amp;goto=news">hide</a> | <a href="item?id=36825481">15&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823375'>
<td align="right" valign="top" class="title"><span class="rank">14.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823375'href='vote?id=36823375&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://maheshba.bitbucket.io/blog/2023/07/12/Design.html" rel="noreferrer">What we talk about when we talk about System Design</a><span class="sitebit comhead"> (<a href="from?site=maheshba.bitbucket.io"><span class="sitestr">maheshba.bitbucket.io</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823375">166 points</span> by <a href="user?id=scv119" class="hnuser">scv119</a> <span class="age" title="2023-07-22T04:47:24"><a href="item?id=36823375">11 hours ago</a></span> <span id="unv_36823375"></span> | <a href="hide?id=36823375&amp;goto=news">hide</a> | <a href="item?id=36823375">22&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823524'>
<td align="right" valign="top" class="title"><span class="rank">15.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823524'href='vote?id=36823524&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.fraunhofer.de/en/research/lighthouse-projects-fraunhofer-initiatives/fraunhofer-lighthouse-projects/elkawe.html" rel="noreferrer">ElKaWe Electrocaloric heat pumps</a><span class="sitebit comhead"> (<a href="from?site=fraunhofer.de"><span class="sitestr">fraunhofer.de</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823524">140 points</span> by <a href="user?id=danans" class="hnuser">danans</a> <span class="age" title="2023-07-22T05:20:10"><a href="item?id=36823524">10 hours ago</a></span> <span id="unv_36823524"></span> | <a href="hide?id=36823524&amp;goto=news">hide</a> | <a href="item?id=36823524">73&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824607'>
<td align="right" valign="top" class="title"><span class="rank">16.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824607'href='vote?id=36824607&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://theecologist.org/2015/jun/05/over-grazing-and-desertification-syrian-steppe-are-root-causes-war" rel="noreferrer">Over-grazing and desertification in the Syrian steppe root causes of war (2015)</a><span class="sitebit comhead"> (<a href="from?site=theecologist.org"><span class="sitestr">theecologist.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824607">64 points</span> by <a href="user?id=joveian" class="hnuser">joveian</a> <span class="age" title="2023-07-22T08:58:09"><a href="item?id=36824607">6 hours ago</a></span> <span id="unv_36824607"></span> | <a href="hide?id=36824607&amp;goto=news">hide</a> | <a href="item?id=36824607">43&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825913'>
<td align="right" valign="top" class="title"><span class="rank">17.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825913'href='vote?id=36825913&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.redmine.org/" rel="noreferrer">Redmine open-source project management</a><span class="sitebit comhead"> (<a href="from?site=redmine.org"><span class="sitestr">redmine.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825913">34 points</span> by <a href="user?id=synergy20" class="hnuser">synergy20</a> <span class="age" title="2023-07-22T13:06:39"><a href="item?id=36825913">2 hours ago</a></span> <span id="unv_36825913"></span> | <a href="hide?id=36825913&amp;goto=news">hide</a> | <a href="item?id=36825913">24&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36797471'>
<td align="right" valign="top" class="title"><span class="rank">18.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36797471'href='vote?id=36797471&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.theregister.com/2023/07/19/google_cuts_internet/" rel="noreferrer">Google tries internet air-gap for some staff PCs</a><span class="sitebit comhead"> (<a href="from?site=theregister.com"><span class="sitestr">theregister.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36797471">67 points</span> by <a href="user?id=beardyw" class="hnuser">beardyw</a> <span class="age" title="2023-07-20T06:35:56"><a href="item?id=36797471">9 hours ago</a></span> <span id="unv_36797471"></span> | <a href="hide?id=36797471&amp;goto=news">hide</a> | <a href="item?id=36797471">73&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36825204'>
<td align="right" valign="top" class="title"><span class="rank">19.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36825204'href='vote?id=36825204&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.science.org/content/article/i-thought-i-wanted-be-faculty-member-then-i-served-hiring-committee" rel="noreferrer">I thought I wanted to be a professor, then I served on a hiring committee (2021)</a><span class="sitebit comhead"> (<a href="from?site=science.org"><span class="sitestr">science.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36825204">104 points</span> by <a href="user?id=ykonstant" class="hnuser">ykonstant</a> <span class="age" title="2023-07-22T11:00:42"><a href="item?id=36825204">4 hours ago</a></span> <span id="unv_36825204"></span> | <a href="hide?id=36825204&amp;goto=news">hide</a> | <a href="item?id=36825204">72&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36822880'>
<td align="right" valign="top" class="title"><span class="rank">20.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36822880'href='vote?id=36822880&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://gwern.net/search" rel="noreferrer">Internet search tips</a><span class="sitebit comhead"> (<a href="from?site=gwern.net"><span class="sitestr">gwern.net</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36822880">161 points</span> by <a href="user?id=herbertl" class="hnuser">herbertl</a> <span class="age" title="2023-07-22T03:22:43"><a href="item?id=36822880">12 hours ago</a></span> <span id="unv_36822880"></span> | <a href="hide?id=36822880&amp;goto=news">hide</a> | <a href="item?id=36822880">58&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36803767'>
<td align="right" valign="top" class="title"><span class="rank">21.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36803767'href='vote?id=36803767&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.sciencedirect.com/science/article/abs/pii/S0094576518314000" rel="noreferrer">Bayesian methods to provide probablistic solution for the Drake equation (2019)</a><span class="sitebit comhead"> (<a href="from?site=sciencedirect.com"><span class="sitestr">sciencedirect.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36803767">22 points</span> by <a href="user?id=benbreen" class="hnuser">benbreen</a> <span class="age" title="2023-07-20T17:28:02"><a href="item?id=36803767">4 hours ago</a></span> <span id="unv_36803767"></span> | <a href="hide?id=36803767&amp;goto=news">hide</a> | <a href="item?id=36803767">18&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824330'>
<td align="right" valign="top" class="title"><span class="rank">22.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824330'href='vote?id=36824330&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://biofabrik.com/biotumen/" rel="noreferrer">Biotumen: Bitumen Reinvented</a><span class="sitebit comhead"> (<a href="from?site=biofabrik.com"><span class="sitestr">biofabrik.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824330">40 points</span> by <a href="user?id=patall" class="hnuser">patall</a> <span class="age" title="2023-07-22T07:57:44"><a href="item?id=36824330">7 hours ago</a></span> <span id="unv_36824330"></span> | <a href="hide?id=36824330&amp;goto=news">hide</a> | <a href="item?id=36824330">11&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36826111'>
<td align="right" valign="top" class="title"><span class="rank">23.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826111'href='vote?id=36826111&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.devever.net/~hl/passwords" rel="noreferrer">Why even let users set their own passwords?</a><span class="sitebit comhead"> (<a href="from?site=devever.net"><span class="sitestr">devever.net</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826111">103 points</span> by <a href="user?id=hlandau" class="hnuser">hlandau</a> <span class="age" title="2023-07-22T13:36:22"><a href="item?id=36826111">2 hours ago</a></span> <span id="unv_36826111"></span> | <a href="hide?id=36826111&amp;goto=news">hide</a> | <a href="item?id=36826111">121&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36784114'>
<td align="right" valign="top" class="title"><span class="rank">24.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36784114'href='vote?id=36784114&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://buildinghealthier.substack.com/p/confronting-failure-as-a-core-life" rel="noreferrer">Confronting failure as a core life skill</a><span class="sitebit comhead"> (<a href="from?site=buildinghealthier.substack.com"><span class="sitestr">buildinghealthier.substack.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36784114">168 points</span> by <a href="user?id=blh75" class="hnuser">blh75</a> <span class="age" title="2023-07-19T10:05:42"><a href="item?id=36784114">15 hours ago</a></span> <span id="unv_36784114"></span> | <a href="hide?id=36784114&amp;goto=news">hide</a> | <a href="item?id=36784114">75&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36808566'>
<td align="right" valign="top" class="title"><span class="rank">25.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36808566'href='vote?id=36808566&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://publicdomainreview.org/collection/hokusai-warriors/" rel="noreferrer">Hokusais Illustrated Warrior Vanguard of Japan and China (1836)</a><span class="sitebit comhead"> (<a href="from?site=publicdomainreview.org"><span class="sitestr">publicdomainreview.org</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36808566">19 points</span> by <a href="user?id=tintinnabula" class="hnuser">tintinnabula</a> <span class="age" title="2023-07-21T00:19:50"><a href="item?id=36808566">2 hours ago</a></span> <span id="unv_36808566"></span> | <a href="hide?id=36808566&amp;goto=news">hide</a> | <a href="item?id=36808566">discuss</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36823723'>
<td align="right" valign="top" class="title"><span class="rank">26.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36823723'href='vote?id=36823723&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://bun.sh/blog/bun-v0.7.0" rel="noreferrer">Bun v0.7.0</a><span class="sitebit comhead"> (<a href="from?site=bun.sh"><span class="sitestr">bun.sh</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36823723">163 points</span> by <a href="user?id=sshroot" class="hnuser">sshroot</a> <span class="age" title="2023-07-22T06:04:11"><a href="item?id=36823723">9 hours ago</a></span> <span id="unv_36823723"></span> | <a href="hide?id=36823723&amp;goto=news">hide</a> | <a href="item?id=36823723">107&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36824856'>
<td align="right" valign="top" class="title"><span class="rank">27.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36824856'href='vote?id=36824856&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://www.simpsonsarchive.com/news/tomacco.html" rel="noreferrer">Simpson Fan Grows Tomacco (2003)</a><span class="sitebit comhead"> (<a href="from?site=simpsonsarchive.com"><span class="sitestr">simpsonsarchive.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36824856">81 points</span> by <a href="user?id=pipeline_peak" class="hnuser">pipeline_peak</a> <span class="age" title="2023-07-22T09:44:39"><a href="item?id=36824856">6 hours ago</a></span> <span id="unv_36824856"></span> | <a href="hide?id=36824856&amp;goto=news">hide</a> | <a href="item?id=36824856">55&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36822530'>
<td align="right" valign="top" class="title"><span class="rank">28.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36822530'href='vote?id=36822530&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://newsreleases.sandia.gov/healing_metals/" rel="noreferrer">Discovery: Metals can heal themselves</a><span class="sitebit comhead"> (<a href="from?site=sandia.gov"><span class="sitestr">sandia.gov</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36822530">77 points</span> by <a href="user?id=bobvanluijt" class="hnuser">bobvanluijt</a> <span class="age" title="2023-07-22T02:19:30"><a href="item?id=36822530">13 hours ago</a></span> <span id="unv_36822530"></span> | <a href="hide?id=36822530&amp;goto=news">hide</a> | <a href="item?id=36822530">24&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36783937'>
<td align="right" valign="top" class="title"><span class="rank">29.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36783937'href='vote?id=36783937&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://genuineideas.com/ArticlesIndex/pressuremarinade.html" rel="noreferrer">Pressure and vacuum marination does not work (2016)</a><span class="sitebit comhead"> (<a href="from?site=genuineideas.com"><span class="sitestr">genuineideas.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36783937">87 points</span> by <a href="user?id=OJFord" class="hnuser">OJFord</a> <span class="age" title="2023-07-19T09:35:28"><a href="item?id=36783937">13 hours ago</a></span> <span id="unv_36783937"></span> | <a href="hide?id=36783937&amp;goto=news">hide</a> | <a href="item?id=36783937">57&nbsp;comments</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class='athing' id='36826664'>
<td align="right" valign="top" class="title"><span class="rank">30.</span></td>
<td valign="top" class="votelinks">
<center>
<a id='up_36826664'href='vote?id=36826664&amp;how=up&amp;goto=news'>
<div class='votearrow' title='upvote'></div>
</a>
</center>
</td>
<td class="title"><span class="titleline"><a href="https://news.mongabay.com/2023/07/scientists-fishing-boats-compete-with-whales-and-penguins-for-antarctic-krill/" rel="nofollow noreferrer">Scientists: Fishing boats compete with whales and penguins for Antarctic krill</a><span class="sitebit comhead"> (<a href="from?site=mongabay.com"><span class="sitestr">mongabay.com</span></a>)</span></span></td>
</tr>
<tr>
<td colspan="2"></td>
<td class="subtext"><span class="subline">
<span class="score" id="score_36826664">5 points</span> by <a href="user?id=PaulHoule" class="hnuser">PaulHoule</a> <span class="age" title="2023-07-22T14:47:25"><a href="item?id=36826664">1 hour ago</a></span> <span id="unv_36826664"></span> | <a href="hide?id=36826664&amp;goto=news">hide</a> | <a href="item?id=36826664">discuss</a> </span>
</td>
</tr>
<tr class="spacer" style="height:5px"></tr>
<tr class="morespace" style="height:10px"></tr>
<tr>
<td colspan="2"></td>
<td class='title'><a href='?p=2' class='morelink' rel='next'>More</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td>
<img src="s.gif" height="10" width="0">
<table width="100%" cellspacing="0" cellpadding="1">
<tr>
<td bgcolor="#ff6600"></td>
</tr>
</table>
<br>
<center>
<span class="yclinks"><a href="newsguidelines.html">Guidelines</a> | <a href="newsfaq.html">FAQ</a> | <a href="lists">Lists</a> | <a href="https://github.com/HackerNews/API">API</a> | <a href="security.html">Security</a> | <a href="https://www.ycombinator.com/legal/">Legal</a> | <a href="https://www.ycombinator.com/apply/">Apply to YC</a> | <a href="mailto:hn@ycombinator.com">Contact</a></span><br><br>
<form method="get" action="//hn.algolia.com/">Search: <input type="text" name="q" size="17" autocorrect="off" spellcheck="false" autocapitalize="off" autocomplete="false"></form>
</center>
</td>
</tr>
</table>
</center>
</body>
<script type='text/javascript' src='hn.js?t8fsBYOw2Gz0ODjGokUo'></script>
</html>

View File

@ -18,9 +18,6 @@ android {
testOptions {
unitTests.returnDefaultValues = true
}
lintOptions {
abortOnError false
}
buildTypes {
release {
minifyEnabled true
@ -41,17 +38,29 @@ android {
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '1.8'
jvmTarget = '17'
}
buildFeatures {
viewBinding true
buildConfig true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.0"
}
lint {
abortOnError false
}
namespace 'com.readrops.app'
}
dependencies {
@ -74,7 +83,7 @@ dependencies {
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.work:work-runtime-ktx:2.8.0"
implementation "androidx.fragment:fragment-ktx:1.3.5"
implementation "androidx.browser:browser:1.3.0"
@ -103,4 +112,21 @@ dependencies {
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'
def composeBom = platform('androidx.compose:compose-bom:2022.12.00')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.activity:activity-compose:1.5.1'
implementation 'androidx.compose.material3:material3'
def voyager = "1.0.0-rc03"
implementation "cafe.adriel.voyager:voyager-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-bottom-sheet-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-tab-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-androidx:$voyager"
//implementation "cafe.adriel.voyager:voyager-koin:$voyager"
debugImplementation "androidx.compose.ui:ui-tooling:1.3.3"
implementation "androidx.compose.ui:ui-tooling-preview:1.3.3"
}

View File

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

View File

@ -1,6 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.readrops.app">
xmlns:tools="http://schemas.android.com/tools">
<application
android:name=".ReadropsDebugApp"

View File

@ -24,7 +24,7 @@ public class ReadropsDebugApp extends ReadropsApp implements Configuration.Provi
super.onCreate();
SoLoader.init(this, false);
initFlipper();
//initFlipper();
}
private void initFlipper() {

View File

@ -1,7 +1,6 @@
<?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">
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
@ -61,7 +60,8 @@
android:name=".itemslist.MainActivity"
android:label="@string/articles"
android:launchMode="singleTask"
android:theme="@style/SplashTheme">
android:theme="@style/SplashTheme"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@ -75,15 +75,16 @@
<activity
android:name=".addfeed.AddFeedActivity"
android:label="@string/add_feed_title"
android:parentActivityName=".itemslist.MainActivity">
android:parentActivityName=".itemslist.MainActivity"
android:exported="true">
<intent-filter android:label="@string/new_feed">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,8 +1,6 @@
package com.readrops.app
import androidx.preference.PreferenceManager
import com.chimerapps.niddler.core.AndroidNiddler
import com.chimerapps.niddler.core.Niddler
import com.readrops.api.services.Credentials
import com.readrops.app.account.AccountViewModel
import com.readrops.app.addfeed.AddFeedsViewModel
@ -63,7 +61,7 @@ val appModule = module {
single { PreferenceManager.getDefaultSharedPreferences(androidContext()) }
single<Niddler> {
/* single<Niddler> {
val niddler = AndroidNiddler.Builder()
.setNiddlerInformation(AndroidNiddler.fromApplication(get()))
.setPort(0)
@ -73,5 +71,5 @@ val appModule = module {
niddler.attachToApplication(get())
niddler.apply { start() }
}
}*/
}

View File

@ -1,5 +1,7 @@
package com.readrops.app.feedsfolders.feeds;
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
import android.app.AlertDialog;
import android.app.Dialog;
import android.os.Bundle;
@ -20,6 +22,8 @@ import com.readrops.db.entities.Folder;
import com.readrops.db.entities.account.Account;
import com.readrops.db.pojo.FeedWithFolder;
import org.koin.android.compat.SharedViewModelCompat;
import java.util.ArrayList;
import java.util.Map;
import java.util.TreeMap;
@ -27,10 +31,6 @@ import java.util.TreeMap;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.schedulers.Schedulers;
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
import org.koin.android.compat.SharedViewModelCompat;
public class EditFeedDialogFragment extends DialogFragment implements AdapterView.OnItemSelectedListener {
private TextInputEditText feedName;
@ -60,7 +60,7 @@ public class EditFeedDialogFragment extends DialogFragment implements AdapterVie
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
viewModel = SharedViewModelCompat.getSharedViewModel(this, ManageFeedsFoldersViewModel.class);
viewModel = SharedViewModelCompat.sharedViewModel(this, ManageFeedsFoldersViewModel.class).getValue();
feedWithFolder = getArguments().getParcelable("feedWithFolder");
account = getArguments().getParcelable(ACCOUNT);

View File

@ -1,6 +1,8 @@
package com.readrops.app.feedsfolders.feeds;
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
import android.content.res.Resources;
import android.os.Bundle;
import android.view.LayoutInflater;
@ -15,21 +17,19 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import com.afollestad.materialdialogs.MaterialDialog;
import com.readrops.app.R;
import com.readrops.app.databinding.FragmentFeedsBinding;
import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel;
import com.readrops.app.utils.SharedPreferencesManager;
import com.readrops.app.utils.Utils;
import com.readrops.app.feedsfolders.ManageFeedsFoldersViewModel;
import com.readrops.db.entities.Feed;
import com.readrops.db.entities.account.Account;
import com.readrops.db.pojo.FeedWithFolder;
import org.koin.android.compat.SharedViewModelCompat;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.observers.DisposableCompletableObserver;
import io.reactivex.schedulers.Schedulers;
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
import org.koin.android.compat.SharedViewModelCompat;
public class FeedsFragment extends Fragment {
@ -64,7 +64,7 @@ public class FeedsFragment extends Fragment {
if (account.getPassword() == null)
account.setPassword(SharedPreferencesManager.readString(account.getPasswordKey()));
viewModel = SharedViewModelCompat.getSharedViewModel(this, ManageFeedsFoldersViewModel.class);
viewModel = SharedViewModelCompat.sharedViewModel(this, ManageFeedsFoldersViewModel.class).getValue();
viewModel.setAccount(account);
viewModel.getFeedsWithFolder().observe(this, feedWithFolders -> {

View File

@ -1,6 +1,8 @@
package com.readrops.app.feedsfolders.folders;
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
import android.content.res.Resources;
import android.os.Bundle;
import android.view.LayoutInflater;
@ -23,14 +25,12 @@ import com.readrops.app.utils.Utils;
import com.readrops.db.entities.Folder;
import com.readrops.db.entities.account.Account;
import org.koin.android.compat.SharedViewModelCompat;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.observers.DisposableSingleObserver;
import io.reactivex.schedulers.Schedulers;
import static com.readrops.app.utils.ReadropsKeys.ACCOUNT;
import org.koin.android.compat.SharedViewModelCompat;
public class FoldersFragment extends Fragment {
private FoldersAdapter adapter;
@ -65,7 +65,7 @@ public class FoldersFragment extends Fragment {
account.setPassword(SharedPreferencesManager.readString(account.getPasswordKey()));
adapter = new FoldersAdapter(this::openFolderOptionsDialog);
viewModel = SharedViewModelCompat.getSharedViewModel(this, ManageFeedsFoldersViewModel.class);
viewModel = SharedViewModelCompat.sharedViewModel(this, ManageFeedsFoldersViewModel.class).getValue();
viewModel.setAccount(account);
viewModel.getFeedCountByAccount()

View File

@ -4,6 +4,7 @@ import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
@ -95,13 +96,19 @@ class SyncWorker(context: Context, parameters: WorkerParameters) : Worker(contex
}
}
val intentFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_IMMUTABLE
}
val notificationBuilder = NotificationCompat.Builder(applicationContext, ReadropsApp.SYNC_CHANNEL_ID)
.setContentTitle(notifContent.title)
.setContentText(notifContent.content)
.setStyle(NotificationCompat.BigTextStyle().bigText(notifContent.content))
.setSmallIcon(R.drawable.ic_notif)
.setContentIntent(PendingIntent.getActivity(applicationContext, 0,
intent, PendingIntent.FLAG_UPDATE_CURRENT))
intent, intentFlag))
.setAutoCancel(true)
notifContent.item?.let {
@ -126,8 +133,14 @@ class SyncWorker(context: Context, parameters: WorkerParameters) : Worker(contex
putExtra(ReadropsKeys.ITEM_ID, item.id)
}
val intentFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_IMMUTABLE
}
return NotificationCompat.Action.Builder(R.drawable.ic_read_later, applicationContext.getString(R.string.read_later),
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT))
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, intentFlag))
.setAllowGeneratedReplies(false)
.build()
}
@ -137,10 +150,19 @@ class SyncWorker(context: Context, parameters: WorkerParameters) : Worker(contex
putExtra(ReadropsKeys.ITEM_ID, item.id)
}
return NotificationCompat.Action.Builder(R.drawable.ic_read, applicationContext.getString(R.string.read),
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_UPDATE_CURRENT))
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
NotificationCompat.Action.Builder(R.drawable.ic_read, applicationContext.getString(R.string.read),
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_IMMUTABLE))
.setAllowGeneratedReplies(false)
.build()
} else {
NotificationCompat.Action.Builder(R.drawable.ic_read, applicationContext.getString(R.string.read),
PendingIntent.getBroadcast(applicationContext, 0, broadcastIntent, PendingIntent.FLAG_IMMUTABLE))
.setAllowGeneratedReplies(false)
.build()
}
}
class MarkReadReceiver : BroadcastReceiver(), KoinComponent {

View File

@ -1,6 +1,5 @@
package com.readrops.app.repositories;
import android.accounts.NetworkErrorException;
import android.content.Context;
import android.os.Handler;
import android.os.Looper;
@ -114,7 +113,7 @@ public class LocalFeedRepository extends ARepository {
} catch (UnknownFormatException e) {
Log.d(TAG, "addFeeds: " + e.getMessage());
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.FORMAT_ERROR);
} catch (NetworkErrorException | IOException e) {
} catch (IOException e) {
Log.d(TAG, "addFeeds: " + e.getMessage());
insertionResult.setInsertionError(FeedInsertionResult.FeedInsertionError.NETWORK_ERROR);
} catch (Exception e) {

View File

@ -1,6 +1,12 @@
package com.readrops.app.settings;
import static android.app.Activity.RESULT_OK;
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_ID;
import static com.readrops.app.utils.ReadropsKeys.EDIT_ACCOUNT;
import android.Manifest;
import android.app.Notification;
import android.app.PendingIntent;
@ -21,7 +27,6 @@ import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import com.afollestad.materialdialogs.MaterialDialog;
import com.readrops.app.utils.OPMLHelper;
import com.readrops.api.opml.OPMLParser;
import com.readrops.app.R;
import com.readrops.app.ReadropsApp;
@ -30,6 +35,7 @@ import com.readrops.app.account.AddAccountActivity;
import com.readrops.app.feedsfolders.ManageFeedsFoldersActivity;
import com.readrops.app.notifications.NotificationPermissionActivity;
import com.readrops.app.utils.FileUtils;
import com.readrops.app.utils.OPMLHelper;
import com.readrops.app.utils.PermissionManager;
import com.readrops.app.utils.SharedPreferencesManager;
import com.readrops.app.utils.Utils;
@ -38,6 +44,8 @@ import com.readrops.db.entities.Folder;
import com.readrops.db.entities.account.Account;
import com.readrops.db.entities.account.AccountType;
import org.koin.android.compat.ViewModelCompat;
import java.io.FileNotFoundException;
import java.util.List;
import java.util.Map;
@ -47,14 +55,6 @@ import io.reactivex.observers.DisposableCompletableObserver;
import io.reactivex.schedulers.Schedulers;
import kotlin.Unit;
import static android.app.Activity.RESULT_OK;
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_ID;
import static com.readrops.app.utils.ReadropsKeys.EDIT_ACCOUNT;
import org.koin.android.compat.ViewModelCompat;
/**
* A simple {@link Fragment} subclass.
*/
@ -274,11 +274,18 @@ public class AccountSettingsFragment extends PreferenceFragmentCompat {
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setDataAndType(Uri.parse(absolutePath), "text/plain");
int intentFlag;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
intentFlag = PendingIntent.FLAG_IMMUTABLE;
} else {
intentFlag = PendingIntent.FLAG_IMMUTABLE;
}
Notification notification = new NotificationCompat.Builder(getContext(), ReadropsApp.OPML_EXPORT_CHANNEL_ID)
.setContentTitle(getString(R.string.opml_export))
.setContentText(name)
.setSmallIcon(R.drawable.ic_notif)
.setContentIntent(PendingIntent.getActivity(getContext(), 0, intent, PendingIntent.FLAG_UPDATE_CURRENT))
.setContentIntent(PendingIntent.getActivity(getContext(), 0, intent, intentFlag))
.setAutoCancel(true)
.build();

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM12,17H6v-2h6V17zM15,13H9v-2h6V13zM18,9h-6V7h6V9z"/>
</vector>

1
appcompose/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

117
appcompose/build.gradle Normal file
View File

@ -0,0 +1,117 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.readrops.app.compose'
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.readrops.app.compose"
minSdk rootProject.ext.minSdkVersion
targetSdk rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
minifyEnabled false
shrinkResources false
testCoverageEnabled true
applicationIdSuffix ".debug"
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
buildFeatures {
buildConfig true
compose true
}
composeOptions {
kotlinCompilerExtensionVersion = "1.4.0"
}
lint {
abortOnError false
}
}
dependencies {
implementation project(':api')
implementation project(':db')
implementation 'androidx.core:core-ktx:1.7.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'com.google.android.material:material:1.8.0'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
def composeBom = platform('androidx.compose:compose-bom:2023.10.01')
implementation composeBom
androidTestImplementation composeBom
implementation 'androidx.palette:palette-ktx:1.0.0'
implementation 'androidx.activity:activity-compose:1.7.2'
implementation 'androidx.compose.material3:material3'
implementation "com.google.accompanist:accompanist-swiperefresh:0.30.1"
def voyager = "1.0.0-rc03"
implementation "cafe.adriel.voyager:voyager-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-bottom-sheet-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-tab-navigator:$voyager"
implementation "cafe.adriel.voyager:voyager-androidx:$voyager"
implementation "cafe.adriel.voyager:voyager-koin:$voyager"
implementation "cafe.adriel.voyager:voyager-transitions:$voyager"
debugImplementation "androidx.compose.ui:ui-tooling:1.4.3"
implementation "androidx.compose.ui:ui-tooling-preview:1.4.3"
def lifecycle_version = "2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycle_version"
implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle_version"
/*def koin_version = "3.3.3"
implementation "io.insert-koin:koin-core:$koin_version"
implementation "io.insert-koin:koin-android:$koin_version"
implementation "io.insert-koin:koin-androidx-compose:3.4.2"*/
androidTestImplementation "io.insert-koin:koin-test-junit4:$rootProject.ext.koin_version"
androidTestImplementation "io.insert-koin:koin-test:$rootProject.ext.koin_version"
androidTestImplementation 'com.squareup.okhttp3:mockwebserver:4.9.0'
implementation "io.coil-kt:coil:2.4.0"
implementation "io.coil-kt:coil-compose:2.4.0"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3"
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1"
implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.6.1"
implementation "androidx.lifecycle:lifecycle-runtime-compose:2.6.1"
}

21
appcompose/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,58 @@
package com.readrops.app.compose
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.compose.util.FeedColors
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okio.Buffer
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.koin.dsl.module
import org.koin.test.KoinTestRule
import java.net.HttpURLConnection
import kotlin.test.assertTrue
class FeedColorsTest {
private val mockServer = MockWebServer()
@Before
fun before() {
val context = ApplicationProvider.getApplicationContext<Context>()
KoinTestRule.create {
modules(apiModule, module {
single { context }
})
}
mockServer.start()
}
@After
fun after() {
mockServer.shutdown()
}
@Test
fun getFeedColorTest() = runBlocking {
val stream = TestUtils.loadResource("favicon.ico")
mockServer.enqueue(
MockResponse()
.setResponseCode(HttpURLConnection.HTTP_OK)
.addHeader(ApiUtils.CONTENT_TYPE_HEADER, "image/jpeg")
.setBody(Buffer().readFrom(stream))
)
val url = mockServer.url("/rss").toString()
val color = FeedColors.getFeedColor(url)
assertTrue { color != 0 }
}
}

View File

@ -0,0 +1,78 @@
package com.readrops.app.compose
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import com.readrops.app.compose.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 kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.joda.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.newAccountDao().insert(account).toInt()
// inserting 3 folders
repeat(3) { time ->
database.newFolderDao()
.insert(Folder(name = "Folder $time", accountId = account.id))
}
// inserting 2 feeds, not linked to any folder
repeat(2) { time ->
database.newFeedDao().insert(Feed(name = "Feed $time", accountId = account.id))
}
// inserting 2 feeds linked to first folder (Folder 0)
repeat(2) { time ->
database.newFeedDao()
.insert(Feed(name = "Feed ${time + 2}", folderId = 1, accountId = account.id))
}
// inserting 3 items linked to first feed (Feed 0)
repeat(3) { time ->
database.newItemDao()
.insert(Item(title = "Item $time", feedId = 1, pubDate = LocalDateTime.now()))
}
}
}
@Test
fun getFoldersWithFeedsTest() = runTest {
getFoldersWithFeeds = GetFoldersWithFeeds(database)
val job = launch {
getFoldersWithFeeds.get(account.id)
.collect { foldersAndFeeds ->
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 }
}
}
// for an unknown reason, the coroutine must be canceled to stop the test, and I don't really know why
job.cancel()
}
}

View File

@ -0,0 +1,113 @@
package com.readrops.app.compose
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.compose.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 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() {
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().compatInsert(account).toInt()
feeds = listOf(
Feed(
name = "feedTest",
url = url.toString(),
accountId = account.id,
),
)
runBlocking {
database.newFeedDao().insert(feeds).run {
feeds.first().id = first().toInt()
}
}
repository = LocalRSSRepository(get(), database, account)
}
@Test
fun synchronizeTest() = 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(null) {
assertEquals(it.name, feeds.first().name)
}
assertTrue { result.first.items.isNotEmpty() }
assertTrue { database.itemDao().itemExists(result.first.items.first().guid!!, 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().guid!!, account.id) }
}
}

View File

@ -0,0 +1,9 @@
package com.readrops.app.compose
import java.io.InputStream
object TestUtils {
fun loadResource(path: String): InputStream =
javaClass.classLoader?.getResourceAsStream(path)!!
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:media="http://search.yahoo.com/mrss/"
xmlns:atom="http://www.w3.org/1999/xhtml">
<channel>
<title>Hacker News</title>
<atom:link href="https://news.ycombinator.com/feed/" rel="self" />
<link>https://news.ycombinator.com/</link>
<description>Links for the intellectually curious, ranked by readers.</description>
<item>
<title>Africa declared free of wild polio</title>
<link>https://www.bbc.com/news/world-africa-53887947</link>
<pubDate>Tue, 25 Aug 2020 17:15:49 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24273602</comments>
<author>Author 1</author>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273602">Comments</a>]]></description>
<media:description>media description</media:description>
</item>
<item>
<title>Palantir S-1</title>
<link>https://www.sec.gov/Archives/edgar/data/1321655/000119312520230013/d904406ds1.htm</link>
<pubDate>Tue, 25 Aug 2020 21:03:42 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24276086</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24276086">Comments</a>]]></description>
</item>
<item>
<title>Openwifi: Linux mac80211 compatible full-stack 802.11/Wi-Fi design based on SDR</title>
<link>https://github.com/open-sdr/openwifi</link>
<pubDate>Tue, 25 Aug 2020 17:45:19 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24273919</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24273919">Comments</a>]]></description>
</item>
<item>
<title>Syllabus for Eric's PhD Students</title>
<link>https://docs.google.com/document/d/11D3kHElzS2HQxTwPqcaTnU5HCJ8WGE5brTXI4KLf4dM/edit</link>
<pubDate>Tue, 25 Aug 2020 18:55:12 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24274699</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274699">Comments</a>]]></description>
</item>
<item>
<title>WebBundles harmful to content blocking, security tools, and the open web</title>
<link>https://brave.com/webbundles-harmful-to-content-blocking-security-tools-and-the-open-web/</link>
<pubDate>Tue, 25 Aug 2020 19:18:50 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24274968</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24274968">Comments</a>]]></description>
</item>
<item>
<title>Zappos CEO Tony Hsieh is stepping down after 21 years</title>
<link>https://footwearnews.com/2020/business/executive-moves/zappos-ceo-tony-hsieh-steps-down-1203045974/</link>
<pubDate>Tue, 25 Aug 2020 06:11:42 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24268522</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24268522">Comments</a>]]></description>
</item>
<item>
<title>Evgeny Kuznetsov practices with Bauer stick that has hole in the blade</title>
<link>https://russianmachineneverbreaks.com/2020/07/17/evgeny-kuznetsov-practices-with-bauer-stick-that-has-hole-in-the-blade/</link>
<pubDate>Tue, 25 Aug 2020 19:38:09 +0000</pubDate>
<comments>https://news.ycombinator.com/item?id=24275159</comments>
<description><![CDATA[<a href="https://news.ycombinator.com/item?id=24275159">Comments</a>]]></description>
</item>
</channel>
</rss>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:name=".ReadropsApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.Readrops">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="Articles">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,32 @@
package com.readrops.app.compose
import com.readrops.app.compose.account.AccountViewModel
import com.readrops.app.compose.account.selection.AccountSelectionViewModel
import com.readrops.app.compose.feeds.FeedViewModel
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.app.compose.repositories.LocalRSSRepository
import com.readrops.app.compose.timelime.TimelineViewModel
import com.readrops.db.entities.account.Account
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
val composeAppModule = module {
viewModel { TimelineViewModel(get(), get()) }
viewModel { FeedViewModel(get(), get(), get()) }
viewModel { AccountSelectionViewModel(get()) }
viewModel { AccountViewModel(get()) }
single { GetFoldersWithFeeds(get()) }
// repositories
factory<BaseRepository> { (account: Account) ->
LocalRSSRepository(get(), get(), account)
}
}

View File

@ -0,0 +1,35 @@
package com.readrops.app.compose
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.material3.*
import cafe.adriel.voyager.navigator.CurrentScreen
import cafe.adriel.voyager.navigator.Navigator
import com.readrops.app.compose.account.selection.AccountSelectionScreen
import com.readrops.app.compose.account.selection.AccountSelectionViewModel
import com.readrops.app.compose.home.HomeScreen
import com.readrops.app.compose.util.theme.ReadropsTheme
import org.koin.androidx.viewmodel.ext.android.getViewModel
class MainActivity : ComponentActivity() {
@OptIn(ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val viewModel = getViewModel<AccountSelectionViewModel>()
val accountExists = viewModel.accountExists()
setContent {
ReadropsTheme {
Navigator(
screen = if (accountExists) HomeScreen() else AccountSelectionScreen()
) {
CurrentScreen()
}
}
}
}
}

View File

@ -0,0 +1,34 @@
package com.readrops.app.compose
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import com.readrops.api.apiModule
import com.readrops.db.dbModule
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
open class ReadropsApp : Application(), KoinComponent, ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger(Level.ERROR)
androidContext(this@ReadropsApp)
modules(apiModule, dbModule, composeAppModule)
}
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.okHttpClient { get() }
.crossfade(true)
.build()
}
}

View File

@ -0,0 +1,180 @@
package com.readrops.app.compose.account
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.material.icons.filled.MoreVert
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
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.graphics.vector.rememberVectorPainter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.app.compose.R
import com.readrops.app.compose.account.credentials.AccountCredentialsScreen
import com.readrops.app.compose.account.selection.AccountSelectionDialog
import com.readrops.app.compose.account.selection.AccountSelectionScreen
import com.readrops.app.compose.util.components.SelectableIconText
import com.readrops.app.compose.util.components.TwoChoicesDialog
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.spacing
import org.koin.androidx.compose.getViewModel
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 viewModel = getViewModel<AccountViewModel>()
val closeHome by viewModel.closeHome.collectAsStateWithLifecycle()
val state by viewModel.accountState.collectAsStateWithLifecycle()
if (closeHome) {
navigator.replaceAll(AccountSelectionScreen())
}
when (state.dialog) {
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 = { viewModel.closeDialog() },
onConfirm = {
viewModel.closeDialog()
viewModel.deleteAccount()
}
)
}
DialogState.NewAccount -> {
AccountSelectionDialog(
onDismiss = { viewModel.closeDialog() },
onValidate = { accountType ->
viewModel.closeDialog()
navigator.push(AccountCredentialsScreen(accountType, state.account))
}
)
}
else -> {}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.account)) },
actions = {
IconButton(
onClick = { }
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { viewModel.openDialog(DialogState.NewAccount) }
) {
Icon(
painter = painterResource(id = R.drawable.ic_add_account),
contentDescription = null
)
}
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Image(
painter = painterResource(id = R.drawable.ic_freshrss),
contentDescription = null,
modifier = Modifier.size(48.dp)
)
MediumSpacer()
Text(
text = state.account.accountName!!,
style = MaterialTheme.typography.titleLarge
)
}
LargeSpacer()
SelectableIconText(
icon = painterResource(id = R.drawable.ic_add_account),
text = stringResource(R.string.credentials),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
onClick = { }
)
SelectableIconText(
icon = painterResource(id = R.drawable.ic_notifications),
text = stringResource(R.string.notifications),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
onClick = { }
)
SelectableIconText(
icon = rememberVectorPainter(image = Icons.Default.AccountCircle),
text = stringResource(R.string.delete_account),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.mediumSpacing,
color = MaterialTheme.colorScheme.error,
tint = MaterialTheme.colorScheme.error,
onClick = { viewModel.openDialog(DialogState.DeleteAccount) }
)
}
}
}
}

View File

@ -0,0 +1,58 @@
package com.readrops.app.compose.account
import androidx.lifecycle.viewModelScope
import com.readrops.app.compose.base.TabViewModel
import com.readrops.db.Database
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AccountViewModel(
private val database: Database
) : TabViewModel(database) {
private val _closeHome = MutableStateFlow(false)
val closeHome = _closeHome.asStateFlow()
private val _accountState = MutableStateFlow(AccountState())
val accountState = _accountState.asStateFlow()
init {
viewModelScope.launch(Dispatchers.IO) {
accountEvent.collect { account ->
_accountState.update {
it.copy(
account = account
)
}
}
}
}
fun openDialog(dialog: DialogState) = _accountState.update { it.copy(dialog = dialog) }
fun closeDialog() = _accountState.update { it.copy(dialog = null) }
fun deleteAccount() {
viewModelScope.launch(Dispatchers.IO) {
database.newAccountDao()
.delete(currentAccount!!)
_closeHome.update { true }
}
}
}
data class AccountState(
val account: Account = Account(accountName = "account", accountType = AccountType.LOCAL),
val dialog: DialogState? = null,
)
sealed interface DialogState {
object DeleteAccount : DialogState
object NewAccount : DialogState
}

View File

@ -0,0 +1,41 @@
package com.readrops.app.compose.account.credentials
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.androidx.AndroidScreen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.app.compose.home.HomeScreen
import com.readrops.db.entities.account.Account
import com.readrops.db.entities.account.AccountType
class AccountCredentialsScreen(
private val accountType: AccountType,
private val account: Account? = null,
) : AndroidScreen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
Column {
Text(
text = "AccountCredentialsScreen"
)
Spacer(modifier = Modifier.size(16.dp))
Button(onClick = { navigator.replaceAll(HomeScreen()) }) {
Text(
text = "skip"
)
}
}
}
}

View File

@ -0,0 +1,41 @@
package com.readrops.app.compose.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.compose.R
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.components.SelectableImageText
import com.readrops.app.compose.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.values().forEach { type ->
SelectableImageText(
image = painterResource(
id = if (type != AccountType.LOCAL)
type.iconRes
else
R.drawable.ic_rss_feed_grey
),
text = stringResource(id = type.typeName),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
padding = MaterialTheme.spacing.shortSpacing,
imageSize = 36.dp,
onClick = { onValidate(type) }
)
}
}
}

View File

@ -0,0 +1,80 @@
package com.readrops.app.compose.account.selection
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.androidx.AndroidScreen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import com.readrops.app.compose.R
import com.readrops.app.compose.account.credentials.AccountCredentialsScreen
import com.readrops.app.compose.home.HomeScreen
import com.readrops.db.entities.account.AccountType
import org.koin.androidx.compose.getViewModel
class AccountSelectionScreen : AndroidScreen() {
@Composable
override fun Content() {
val viewModel = getViewModel<AccountSelectionViewModel>()
val navState by viewModel.navState.collectAsStateWithLifecycle()
val navigator = LocalNavigator.currentOrThrow
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize()
) {
Text(text = "Choose an account")
Spacer(modifier = Modifier.size(8.dp))
AccountType.values().forEach { accountType ->
Row(
modifier = Modifier.clickable { viewModel.createAccount(accountType) }
) {
Icon(
painter = painterResource(id = R.drawable.ic_freshrss),
contentDescription = accountType.name,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.size(4.dp))
Text(text = accountType.name)
}
Spacer(modifier = Modifier.size(8.dp))
}
}
when (navState) {
is AccountSelectionViewModel.NavState.GoToHomeScreen -> {
// using replace makes the app crash due to a screen key conflict
navigator.replaceAll(HomeScreen())
}
is AccountSelectionViewModel.NavState.GoToAccountCredentialsScreen -> {
val accountType = (navState as AccountSelectionViewModel.NavState.GoToAccountCredentialsScreen).accountType
navigator.push(AccountCredentialsScreen(accountType))
viewModel.resetNavState()
}
else -> {}
}
}
}

View File

@ -0,0 +1,69 @@
package com.readrops.app.compose.account.selection
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.readrops.db.Database
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.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
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
class AccountSelectionViewModel(
private val database: Database,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : ViewModel(), KoinComponent {
private val _navState = MutableStateFlow<NavState>(NavState.Idle)
val navState = _navState.asStateFlow()
fun accountExists(): Boolean {
val accountCount = runBlocking {
database.newAccountDao().selectAccountCount()
}
return accountCount > 0
}
fun createAccount(accountType: AccountType) {
if (accountType == AccountType.LOCAL) {
createLocalAccount()
} else {
_navState.update { NavState.GoToAccountCredentialsScreen(accountType) }
}
}
fun resetNavState() {
_navState.update { NavState.Idle }
}
private fun createLocalAccount() {
val context = get<Context>()
val account = Account(
url = null,
accountName = context.getString(AccountType.LOCAL.typeName),
accountType = AccountType.LOCAL,
isCurrentAccount = true
)
viewModelScope.launch(dispatcher) {
database.newAccountDao().insert(account)
_navState.update { NavState.GoToHomeScreen }
}
}
sealed class NavState {
object Idle : NavState()
object GoToHomeScreen : NavState()
class GoToAccountCredentialsScreen(val accountType: AccountType) : NavState()
}
}

View File

@ -0,0 +1,47 @@
package com.readrops.app.compose.base
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.readrops.app.compose.repositories.BaseRepository
import com.readrops.db.Database
import com.readrops.db.entities.account.Account
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import org.koin.core.parameter.parametersOf
/**
* Custom ViewModel for Tab screens handling account change
*/
abstract class TabViewModel(
private val database: Database,
) : ViewModel(), KoinComponent {
/**
* Repository intended to be rebuilt when the current account changes
*/
protected var repository: BaseRepository? = null
protected var currentAccount: Account? = null
protected val accountEvent = MutableSharedFlow<Account>()
init {
viewModelScope.launch {
database.newAccountDao()
.selectCurrentAccount()
.distinctUntilChanged()
.collect { account ->
if (account != null) {
currentAccount = account
repository = get(parameters = { parametersOf(account) })
accountEvent.emit(account)
}
}
}
}
}

View File

@ -0,0 +1,60 @@
package com.readrops.app.compose.feeds
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
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.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.app.compose.util.toDp
import com.readrops.db.entities.Feed
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FeedItem(
feed: Feed,
onClick: () -> Unit,
onLongClick: () -> Unit,
) {
Box(
modifier = Modifier.combinedClickable(
onClick = onClick,
onLongClick = onLongClick
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = MaterialTheme.spacing.mediumSpacing,
vertical = MaterialTheme.spacing.shortSpacing
)
) {
AsyncImage(
model = feed.iconUrl,
contentDescription = feed.name!!,
modifier = Modifier.size(MaterialTheme.typography.bodyLarge.toDp())
)
ShortSpacer()
Text(
text = feed.name!!,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}

View File

@ -0,0 +1,70 @@
package com.readrops.app.compose.feeds
import com.readrops.app.compose.util.components.TextFieldError
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
data class FeedState(
val foldersAndFeeds: FolderAndFeedsState = FolderAndFeedsState.InitialState,
val dialog: DialogState? = null,
val areFoldersExpanded: Boolean = false
)
sealed interface DialogState {
object AddFeed : DialogState
object AddFolder : DialogState
class DeleteFeed(val feed: Feed) : DialogState
class DeleteFolder(val folder: Folder) : DialogState
class UpdateFeed(val feed: Feed, val folder: Folder?) : DialogState
class UpdateFolder(val folder: Folder) : DialogState
class FeedSheet(val feed: Feed, val folder: Folder?) : DialogState
}
sealed class FolderAndFeedsState {
object InitialState : FolderAndFeedsState()
data class ErrorState(val exception: Exception) : FolderAndFeedsState()
data class LoadedState(val values: Map<Folder?, List<Feed>>) : FolderAndFeedsState()
}
data class AddFeedDialogState(
val url: String = "",
val selectedAccount: Account = Account(accountName = ""),
val accounts: List<Account> = listOf(),
val error: TextFieldError? = null,
) {
val isError: Boolean get() = error != null
}
data class UpdateFeedDialogState(
val feedId: Int = 0,
val feedName: String = "",
val feedNameError: TextFieldError? = null,
val feedUrl: String = "",
val feedUrlError: TextFieldError? = null,
val accountType: AccountType? = null,
val selectedFolder: Folder? = null,
val folders: List<Folder> = listOf(),
val isAccountDropDownExpanded: Boolean = false,
) {
val isFeedNameError
get() = feedNameError != null
val isFeedUrlError
get() = feedUrlError != null
val isFeedUrlReadOnly: Boolean
get() = accountType != null && !accountType.accountConfig!!.isFeedUrlEditable
val hasFolders = folders.isNotEmpty()
}
data class FolderState(
val folder: Folder = Folder(),
val nameError: TextFieldError? = null,
) {
val name = folder.name
val isError = nameError != null
}

View File

@ -0,0 +1,294 @@
package com.readrops.app.compose.feeds
import androidx.compose.foundation.layout.Box
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.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SmallFloatingActionButton
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.readrops.app.compose.R
import com.readrops.app.compose.feeds.dialogs.AddFeedDialog
import com.readrops.app.compose.feeds.dialogs.FeedModalBottomSheet
import com.readrops.app.compose.feeds.dialogs.FolderDialog
import com.readrops.app.compose.feeds.dialogs.UpdateFeedDialog
import com.readrops.app.compose.util.components.CenteredProgressIndicator
import com.readrops.app.compose.util.components.ErrorMessage
import com.readrops.app.compose.util.components.Placeholder
import com.readrops.app.compose.util.components.TwoChoicesDialog
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.Feed
import org.koin.androidx.compose.getViewModel
object FeedTab : Tab {
override val options: TabOptions
@Composable
get() = TabOptions(
index = 2u,
title = stringResource(R.string.feeds)
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val haptic = LocalHapticFeedback.current
val uriHandler = LocalUriHandler.current
val viewModel = getViewModel<FeedViewModel>()
val state by viewModel.feedsState.collectAsStateWithLifecycle()
when (val dialog = state.dialog) {
is DialogState.AddFeed -> {
AddFeedDialog(
viewModel = viewModel,
onDismiss = {
viewModel.closeDialog()
viewModel.resetAddFeedDialogState()
},
)
}
is DialogState.DeleteFeed -> {
TwoChoicesDialog(
title = stringResource(R.string.delete_feed),
text = "Do you want to delete feed ${dialog.feed.name}?",
icon = rememberVectorPainter(image = Icons.Default.Delete),
confirmText = stringResource(R.string.delete),
dismissText = stringResource(R.string.cancel),
onDismiss = { viewModel.closeDialog() },
onConfirm = {
viewModel.deleteFeed(dialog.feed)
viewModel.closeDialog()
}
)
}
is DialogState.FeedSheet -> {
FeedModalBottomSheet(
feed = dialog.feed,
folder = dialog.folder,
onDismissRequest = { viewModel.closeDialog() },
onOpen = {
uriHandler.openUri(dialog.feed.siteUrl!!)
viewModel.closeDialog()
},
onUpdate = {
viewModel.openDialog(
DialogState.UpdateFeed(
dialog.feed,
dialog.folder
)
)
},
onUpdateColor = {},
onDelete = { viewModel.openDialog(DialogState.DeleteFeed(dialog.feed)) },
)
}
is DialogState.UpdateFeed -> {
UpdateFeedDialog(
viewModel = viewModel,
onDismissRequest = { viewModel.closeDialog() }
)
}
DialogState.AddFolder -> {
FolderDialog(
viewModel = viewModel,
onDismiss = {
viewModel.closeDialog()
viewModel.resetFolderState()
},
onValidate = {
viewModel.folderValidate()
}
)
}
is DialogState.DeleteFolder -> {
TwoChoicesDialog(
title = stringResource(R.string.delete_folder),
text = "Do you want to delete folder ${dialog.folder.name}?",
icon = rememberVectorPainter(image = Icons.Default.Delete),
confirmText = stringResource(R.string.delete),
dismissText = stringResource(R.string.cancel),
onDismiss = { viewModel.closeDialog() },
onConfirm = {
viewModel.deleteFolder(dialog.folder)
viewModel.closeDialog()
}
)
}
is DialogState.UpdateFolder -> {
FolderDialog(
updateFolder = true,
viewModel = viewModel,
onDismiss = {
viewModel.closeDialog()
viewModel.resetFolderState()
},
onValidate = {
viewModel.folderValidate(updateFolder = true)
}
)
}
null -> {}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = stringResource(R.string.feeds)) },
actions = {
IconButton(
onClick = { viewModel.setFolderExpandState(state.areFoldersExpanded.not()) }
) {
Icon(
painter = painterResource(id = if (state.areFoldersExpanded)
R.drawable.ic_unfold_less
else
R.drawable.ic_unfold_more),
contentDescription = null
)
}
}
)
},
floatingActionButton = {
Column {
SmallFloatingActionButton(
modifier = Modifier
.padding(
start = MaterialTheme.spacing.veryShortSpacing,
bottom = MaterialTheme.spacing.shortSpacing
),
onClick = { viewModel.openDialog(DialogState.AddFolder) }
) {
Icon(
painter = painterResource(id = R.drawable.ic_new_folder),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
FloatingActionButton(
onClick = { viewModel.openDialog(DialogState.AddFeed) }
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = null
)
}
}
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
when (state.foldersAndFeeds) {
is FolderAndFeedsState.LoadedState -> {
val foldersAndFeeds =
(state.foldersAndFeeds as FolderAndFeedsState.LoadedState).values
if (foldersAndFeeds.isNotEmpty()) {
LazyColumn {
items(
items = foldersAndFeeds.toList()
) { folderWithFeeds ->
fun onFeedLongClick(feed: Feed) {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
uriHandler.openUri(feed.siteUrl!!)
}
if (folderWithFeeds.first != null) {
val folder = folderWithFeeds.first!!
FolderExpandableItem(
folder = folder,
feeds = folderWithFeeds.second,
isExpanded = state.areFoldersExpanded,
onFeedClick = { feed ->
viewModel.openDialog(
DialogState.FeedSheet(feed, folder)
)
},
onFeedLongClick = { feed -> onFeedLongClick(feed) },
onUpdateFolder = {
viewModel.openDialog(
DialogState.UpdateFolder(folder)
)
},
onDeleteFolder = {
viewModel.openDialog(
DialogState.DeleteFolder(folder)
)
}
)
} else {
val feeds = folderWithFeeds.second
for (feed in feeds) {
FeedItem(
feed = feed,
onClick = {
viewModel.openDialog(
DialogState.FeedSheet(feed, null)
)
},
onLongClick = { onFeedLongClick(feed) },
)
}
}
}
}
} else {
Placeholder(
text = stringResource(R.string.no_feed),
painter = painterResource(R.drawable.ic_rss_feed_grey)
)
}
}
is FolderAndFeedsState.InitialState -> {
CenteredProgressIndicator()
}
is FolderAndFeedsState.ErrorState -> {
val exception = (state.foldersAndFeeds as FolderAndFeedsState.ErrorState).exception
ErrorMessage(exception = exception)
}
}
}
}
}
}

View File

@ -0,0 +1,329 @@
package com.readrops.app.compose.feeds
import android.util.Patterns
import androidx.lifecycle.viewModelScope
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.utils.HtmlParser
import com.readrops.app.compose.base.TabViewModel
import com.readrops.app.compose.repositories.GetFoldersWithFeeds
import com.readrops.app.compose.util.components.TextFieldError
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flatMapConcat
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
import java.net.UnknownHostException
@OptIn(ExperimentalCoroutinesApi::class)
class FeedViewModel(
database: Database,
private val getFoldersWithFeeds: GetFoldersWithFeeds,
private val localRSSDataSource: LocalRSSDataSource,
) : TabViewModel(database), KoinComponent {
private val _feedState = MutableStateFlow(FeedState())
val feedsState = _feedState.asStateFlow()
private val _addFeedDialogState = MutableStateFlow(AddFeedDialogState())
val addFeedDialogState = _addFeedDialogState.asStateFlow()
private val _updateFeedDialogState = MutableStateFlow(UpdateFeedDialogState())
val updateFeedDialogState = _updateFeedDialogState.asStateFlow()
private val _folderState = MutableStateFlow(FolderState())
val folderState = _folderState.asStateFlow()
init {
viewModelScope.launch(context = Dispatchers.IO) {
accountEvent
.flatMapConcat { account ->
getFoldersWithFeeds.get(account.id)
}
.catch { throwable ->
_feedState.update {
it.copy(foldersAndFeeds = FolderAndFeedsState.ErrorState(Exception(throwable)))
}
}
.collect { foldersAndFeeds ->
_feedState.update {
it.copy(foldersAndFeeds = FolderAndFeedsState.LoadedState(foldersAndFeeds))
}
}
}
viewModelScope.launch(context = Dispatchers.IO) {
database.newAccountDao()
.selectAllAccounts()
.collect { accounts ->
_addFeedDialogState.update { dialogState ->
dialogState.copy(
accounts = accounts,
selectedAccount = accounts.find { it.isCurrentAccount }!!
)
}
}
}
viewModelScope.launch(context = Dispatchers.IO) {
accountEvent
.flatMapConcat { account ->
database.newFolderDao()
.selectFolders(account.id)
}
.collect { folders ->
_updateFeedDialogState.update {
it.copy(
folders = folders,
accountType = currentAccount!!.accountType
)
}
}
}
}
fun setFolderExpandState(isExpanded: Boolean) =
_feedState.update { it.copy(areFoldersExpanded = isExpanded) }
fun closeDialog() = _feedState.update { it.copy(dialog = null) }
fun openDialog(state: DialogState) {
if (state is DialogState.UpdateFeed) {
_updateFeedDialogState.update {
it.copy(
feedId = state.feed.id,
feedName = state.feed.name!!,
feedUrl = state.feed.url!!,
selectedFolder = state.folder
)
}
}
if (state is DialogState.UpdateFolder) {
_folderState.update {
it.copy(
folder = state.folder
)
}
}
_feedState.update { it.copy(dialog = state) }
}
fun deleteFeed(feed: Feed) {
viewModelScope.launch(Dispatchers.IO) {
repository?.deleteFeed(feed)
}
}
fun deleteFolder(folder: Folder) {
viewModelScope.launch(Dispatchers.IO) {
repository?.deleteFolder(folder)
}
}
// Add feed
fun setAddFeedDialogURL(url: String) {
_addFeedDialogState.update {
it.copy(
url = url,
error = null,
)
}
}
fun setAddFeedDialogSelectedAccount(account: Account) {
_addFeedDialogState.update { it.copy(selectedAccount = account) }
}
fun addFeedDialogValidate() {
val url = _addFeedDialogState.value.url
when {
url.isEmpty() -> {
_addFeedDialogState.update {
it.copy(error = TextFieldError.EmptyField)
}
return
}
!Patterns.WEB_URL.matcher(url).matches() -> {
_addFeedDialogState.update {
it.copy(error = TextFieldError.BadUrl)
}
return
}
else -> viewModelScope.launch(Dispatchers.IO) {
try {
if (localRSSDataSource.isUrlRSSResource(url)) {
// TODO add support for all account types
repository?.insertNewFeeds(listOf(url))
closeDialog()
} else {
val rssUrls = HtmlParser.getFeedLink(url, get())
if (rssUrls.isEmpty()) {
_addFeedDialogState.update {
it.copy(error = TextFieldError.NoRSSFeed)
}
} else {
// TODO add support for all account types
repository?.insertNewFeeds(rssUrls.map { it.url })
closeDialog()
}
}
} catch (e: Exception) {
when (e) {
is UnknownHostException -> _addFeedDialogState.update { it.copy(error = TextFieldError.UnreachableUrl) }
else -> _addFeedDialogState.update { it.copy(error = TextFieldError.NoRSSFeed) }
}
}
}
}
}
fun resetAddFeedDialogState() {
_addFeedDialogState.update {
it.copy(
url = "",
error = null,
)
}
}
// add feed
// update feed
fun setAccountDropDownState(isExpanded: Boolean) {
_updateFeedDialogState.update {
it.copy(isAccountDropDownExpanded = isExpanded)
}
}
fun setSelectedFolder(folder: Folder) {
_updateFeedDialogState.update {
it.copy(selectedFolder = folder)
}
}
fun setUpdateFeedDialogStateFeedName(feedName: String) {
_updateFeedDialogState.update {
it.copy(
feedName = feedName,
feedNameError = null,
)
}
}
fun setUpdateFeedDialogFeedUrl(feedUrl: String) {
_updateFeedDialogState.update {
it.copy(
feedUrl = feedUrl,
feedUrlError = null,
)
}
}
fun updateFeedDialogValidate() {
val feedName = _updateFeedDialogState.value.feedName
val feedUrl = _updateFeedDialogState.value.feedUrl
when {
feedName.isEmpty() -> {
_updateFeedDialogState.update {
it.copy(feedNameError = TextFieldError.EmptyField)
}
return
}
feedUrl.isEmpty() -> {
_updateFeedDialogState.update {
it.copy(feedUrlError = TextFieldError.EmptyField)
}
return
}
!Patterns.WEB_URL.matcher(feedUrl).matches() -> {
_updateFeedDialogState.update {
it.copy(feedUrlError = TextFieldError.BadUrl)
}
return
}
else -> {
viewModelScope.launch(Dispatchers.IO) {
with(_updateFeedDialogState.value) {
repository?.updateFeed(
Feed(
id = feedId,
name = feedName,
url = feedUrl,
folderId = selectedFolder?.id
)
)
}
closeDialog()
}
}
}
}
// update feed
// add/update folder
fun setFolderName(name: String) = _folderState.update {
it.copy(
folder = it.folder.copy(name = name),
nameError = null,
)
}
fun resetFolderState() = _folderState.update {
it.copy(
folder = Folder(),
nameError = null,
)
}
fun folderValidate(updateFolder: Boolean = false) {
val name = _folderState.value.name.orEmpty()
if (name.isEmpty()) {
_folderState.update { it.copy(nameError = TextFieldError.EmptyField) }
return
}
viewModelScope.launch(Dispatchers.IO) {
if (updateFolder) {
repository?.updateFolder(_folderState.value.folder)
} else {
repository?.addFolder(_folderState.value.folder.apply {
accountId = currentAccount!!.id
})
}
closeDialog()
resetFolderState()
}
}
// add/update folder
}

View File

@ -0,0 +1,143 @@
package com.readrops.app.compose.feeds
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import com.readrops.app.compose.R
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
@Composable
fun FolderExpandableItem(
folder: Folder,
feeds: List<Feed>,
isExpanded: Boolean = false,
onFeedClick: (Feed) -> Unit,
onFeedLongClick: (Feed) -> Unit,
onUpdateFolder: () -> Unit,
onDeleteFolder: () -> Unit
) {
var isFolderExpanded by remember { mutableStateOf(false) }
var isDropDownMenuExpanded by remember { mutableStateOf(false) }
LaunchedEffect(isExpanded) {
isFolderExpanded = isExpanded
}
Column(
modifier = Modifier
.animateContentSize(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing,
)
)
) {
Column(
modifier = Modifier
.clickable { isFolderExpanded = isFolderExpanded.not() }
.padding(
start = MaterialTheme.spacing.mediumSpacing,
top = MaterialTheme.spacing.veryShortSpacing,
bottom = MaterialTheme.spacing.veryShortSpacing
)
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxSize()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
painter = painterResource(R.drawable.ic_folder_grey),
contentDescription = folder.name
)
MediumSpacer()
Text(
text = folder.name!!,
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Box {
IconButton(
onClick = { isDropDownMenuExpanded = isDropDownMenuExpanded.not() }
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null,
)
}
DropdownMenu(
expanded = isDropDownMenuExpanded,
onDismissRequest = { isDropDownMenuExpanded = false },
) {
DropdownMenuItem(
text = { Text(text = "Update") },
onClick = {
isDropDownMenuExpanded = false
onUpdateFolder()
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.delete)) },
onClick = {
isDropDownMenuExpanded = false
onDeleteFolder()
}
)
}
}
}
}
Column {
if (isFolderExpanded) {
for (feed in feeds) {
FeedItem(
feed = feed,
onClick = { onFeedClick(feed) },
onLongClick = { onFeedLongClick(feed) },
)
}
}
}
}
}

View File

@ -0,0 +1,129 @@
package com.readrops.app.compose.feeds.dialogs
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.readrops.app.compose.R
import com.readrops.app.compose.feeds.FeedViewModel
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AddFeedDialog(
viewModel: FeedViewModel,
onDismiss: () -> Unit,
) {
val state by viewModel.addFeedDialogState.collectAsStateWithLifecycle()
var isExpanded by remember { mutableStateOf(false) }
BaseDialog(
title = stringResource(R.string.add_feed_item),
icon = painterResource(id = R.drawable.ic_rss_feed_grey),
onDismiss = onDismiss
) {
OutlinedTextField(
value = state.url,
label = {
Text(text = "URL")
},
onValueChange = { viewModel.setAddFeedDialogURL(it) },
singleLine = true,
trailingIcon = {
if (state.url.isNotEmpty()) {
IconButton(
onClick = { viewModel.setAddFeedDialogURL("") }
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = null
)
}
}
},
isError = state.isError,
supportingText = { Text(state.error?.errorText().orEmpty()) }
)
ShortSpacer()
ExposedDropdownMenuBox(
expanded = isExpanded,
onExpandedChange = { isExpanded = isExpanded.not() }
) {
ExposedDropdownMenu(
expanded = isExpanded,
onDismissRequest = { isExpanded = false }
) {
for (account in state.accounts) {
DropdownMenuItem(
text = { Text(text = account.accountName!!) },
onClick = {
isExpanded = false
viewModel.setAddFeedDialogSelectedAccount(account)
},
leadingIcon = {
Icon(
painter = painterResource(
id = if (state.selectedAccount.isLocal) {
R.drawable.ic_rss_feed_grey
} else
state.selectedAccount.accountType!!.iconRes
),
contentDescription = null
)
}
)
}
}
OutlinedTextField(
value = state.selectedAccount.accountName!!,
readOnly = true,
onValueChange = {},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = isExpanded)
},
leadingIcon = {
Icon(
painter = painterResource(
id = if (state.selectedAccount.isLocal) {
R.drawable.ic_rss_feed_grey
} else
state.selectedAccount.accountType!!.iconRes
),
contentDescription = null
)
},
modifier = Modifier.menuAnchor()
)
}
LargeSpacer()
TextButton(
onClick = { viewModel.addFeedDialogValidate() },
) {
Text(text = stringResource(R.string.validate))
}
}
}

View File

@ -0,0 +1,156 @@
package com.readrops.app.compose.feeds.dialogs
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.Create
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.Divider
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.style.TextOverflow
import coil.compose.AsyncImage
import com.readrops.app.compose.R
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.VeryShortSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedModalBottomSheet(
feed: Feed,
folder: Folder?,
onDismissRequest: () -> Unit,
onOpen: () -> Unit,
onUpdate: () -> Unit,
onUpdateColor: () -> Unit,
onDelete: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = { onDismissRequest() }
) {
Column {
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(
horizontal = MaterialTheme.spacing.largeSpacing
)
) {
AsyncImage(
model = feed.iconUrl,
contentDescription = feed.name!!,
modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing)
)
MediumSpacer()
Column {
Text(
text = feed.name!!,
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (folder != null) {
VeryShortSpacer()
Text(
text = folder.name!!,
style = MaterialTheme.typography.labelLarge
)
}
}
}
MediumSpacer()
Divider(
modifier = Modifier.padding(
horizontal = MaterialTheme.spacing.mediumSpacing
)
)
MediumSpacer()
BottomSheetOption(
text = stringResource(R.string.open),
icon = ImageVector.vectorResource(id = R.drawable.ic_open_in_browser),
onClick = onOpen
)
BottomSheetOption(
text = "Update",
icon = Icons.Default.Create,
onClick = onUpdate
)
BottomSheetOption(
text = "Update color",
icon = ImageVector.vectorResource(R.drawable.ic_color),
onClick = onUpdateColor
)
BottomSheetOption(
text = stringResource(R.string.delete),
icon = Icons.Default.Delete,
onClick = onDelete
)
}
LargeSpacer()
}
}
@Composable
fun BottomSheetOption(
text: String,
icon: ImageVector,
onClick: () -> Unit,
) {
Box(
modifier = Modifier.clickable { onClick() }
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(
horizontal = MaterialTheme.spacing.mediumSpacing,
vertical = MaterialTheme.spacing.shortSpacing
)
) {
Icon(
imageVector = icon,
contentDescription = text
)
MediumSpacer()
Text(
text = text,
style = MaterialTheme.typography.bodyMedium
)
}
}
}

View File

@ -0,0 +1,65 @@
package com.readrops.app.compose.feeds.dialogs
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Clear
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.readrops.app.compose.R
import com.readrops.app.compose.feeds.FeedViewModel
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.theme.LargeSpacer
@Composable
fun FolderDialog(
updateFolder: Boolean = false,
viewModel: FeedViewModel,
onDismiss: () -> Unit,
onValidate: () -> Unit
) {
val state by viewModel.folderState.collectAsStateWithLifecycle()
BaseDialog(
title = stringResource(id = if (updateFolder) R.string.edit_folder else R.string.add_folder),
icon = painterResource(id = if (updateFolder) R.drawable.ic_folder_grey else R.drawable.ic_new_folder),
onDismiss = onDismiss
) {
OutlinedTextField(
value = state.name.orEmpty(),
label = {
Text(text = "URL")
},
onValueChange = { viewModel.setFolderName(it) },
singleLine = true,
trailingIcon = {
if (!state.name.isNullOrEmpty()) {
IconButton(
onClick = { viewModel.setFolderName("") }
) {
Icon(
imageVector = Icons.Default.Clear,
contentDescription = null
)
}
}
},
isError = state.isError,
supportingText = { Text(text = state.nameError?.errorText().orEmpty()) }
)
LargeSpacer()
TextButton(
onClick = { onValidate() },
) {
Text(text = stringResource(R.string.validate))
}
}
}

View File

@ -0,0 +1,125 @@
package com.readrops.app.compose.feeds.dialogs
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.readrops.app.compose.R
import com.readrops.app.compose.feeds.FeedViewModel
import com.readrops.app.compose.util.components.BaseDialog
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.MediumSpacer
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UpdateFeedDialog(
viewModel: FeedViewModel,
onDismissRequest: () -> Unit
) {
val state by viewModel.updateFeedDialogState.collectAsStateWithLifecycle()
BaseDialog(
title = stringResource(R.string.edit_feed),
icon = painterResource(id = R.drawable.ic_rss_feed_grey),
onDismiss = onDismissRequest
) {
OutlinedTextField(
value = state.feedName,
onValueChange = { viewModel.setUpdateFeedDialogStateFeedName(it) },
label = { Text(text = stringResource(R.string.feed_name)) },
singleLine = true,
isError = state.isFeedNameError,
supportingText = {
if (state.isFeedNameError) {
Text(
text = state.feedNameError?.errorText().orEmpty()
)
}
}
)
MediumSpacer()
OutlinedTextField(
value = state.feedUrl,
onValueChange = { viewModel.setUpdateFeedDialogFeedUrl(it) },
label = { Text(text = stringResource(R.string.feed_url)) },
singleLine = true,
readOnly = state.isFeedUrlReadOnly,
isError = state.isFeedUrlError,
supportingText = {
if (state.isFeedUrlError) {
Text(
text = state.feedUrlError?.errorText().orEmpty()
)
}
}
)
MediumSpacer()
ExposedDropdownMenuBox(
expanded = state.isAccountDropDownExpanded && state.hasFolders,
onExpandedChange = { viewModel.setAccountDropDownState(state.isAccountDropDownExpanded.not()) }
) {
ExposedDropdownMenu(
expanded = state.isAccountDropDownExpanded && state.hasFolders,
onDismissRequest = { viewModel.setAccountDropDownState(false) }
) {
for (folder in state.folders) {
DropdownMenuItem(
text = { Text(text = folder.name!!) },
onClick = {
viewModel.setSelectedFolder(folder)
viewModel.setAccountDropDownState(false)
},
leadingIcon = {
Icon(
painterResource(id = R.drawable.ic_folder_grey),
contentDescription = null,
)
}
)
}
}
OutlinedTextField(
value = state.selectedFolder?.name.orEmpty(),
readOnly = true,
enabled = state.hasFolders,
onValueChange = {},
trailingIcon = {
ExposedDropdownMenuDefaults.TrailingIcon(expanded = state.isAccountDropDownExpanded)
},
leadingIcon = {
if (state.selectedFolder != null) {
Icon(
painterResource(id = R.drawable.ic_folder_grey),
contentDescription = null,
)
}
},
modifier = Modifier.menuAnchor()
)
}
LargeSpacer()
TextButton(
onClick = { viewModel.updateFeedDialogValidate() },
) {
Text(text = stringResource(R.string.validate))
}
}
}

View File

@ -0,0 +1,107 @@
package com.readrops.app.compose.home
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AccountCircle
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.BottomAppBar
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import cafe.adriel.voyager.androidx.AndroidScreen
import cafe.adriel.voyager.navigator.LocalNavigator
import cafe.adriel.voyager.navigator.currentOrThrow
import cafe.adriel.voyager.navigator.tab.CurrentTab
import cafe.adriel.voyager.navigator.tab.TabNavigator
import com.readrops.app.compose.R
import com.readrops.app.compose.account.AccountTab
import com.readrops.app.compose.feeds.FeedTab
import com.readrops.app.compose.more.MoreTab
import com.readrops.app.compose.timelime.TimelineTab
class HomeScreen : AndroidScreen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
TabNavigator(
tab = TimelineTab
) { tabNavigator ->
CompositionLocalProvider(LocalNavigator provides navigator) {
Scaffold(
bottomBar = {
BottomAppBar {
NavigationBarItem(
selected = tabNavigator.current.key == TimelineTab.key,
onClick = { tabNavigator.current = TimelineTab },
icon = {
Icon(
painter = painterResource(R.drawable.ic_timeline),
contentDescription = null
)
},
label = { Text("Timeline") }
)
NavigationBarItem(
selected = tabNavigator.current.key == FeedTab.key,
onClick = { tabNavigator.current = FeedTab },
icon = {
Icon(
painter = painterResource(R.drawable.ic_rss_feed_grey),
contentDescription = null
)
},
label = { Text(text = stringResource(R.string.feeds)) }
)
NavigationBarItem(
selected = tabNavigator.current.key == AccountTab.key,
onClick = { tabNavigator.current = AccountTab },
icon = {
Icon(
imageVector = Icons.Default.AccountCircle,
contentDescription = null,
)
},
label = { Text(text = stringResource(R.string.account)) }
)
NavigationBarItem(
selected = tabNavigator.current.key == MoreTab.key,
onClick = { tabNavigator.current = MoreTab },
icon = {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = null,
)
},
label = { Text("More") }
)
}
},
) { paddingValues ->
Box(
modifier = Modifier.padding(paddingValues)
) {
CurrentTab()
}
BackHandler(
enabled = tabNavigator.current != TimelineTab,
onBack = { tabNavigator.current = TimelineTab }
)
}
}
}
}
}

View File

@ -0,0 +1,13 @@
package com.readrops.app.compose.item
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import cafe.adriel.voyager.androidx.AndroidScreen
class ItemScreen : AndroidScreen() {
@Composable
override fun Content() {
Text(text ="item screen")
}
}

View File

@ -0,0 +1,90 @@
package com.readrops.app.compose.more
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import cafe.adriel.voyager.navigator.tab.Tab
import cafe.adriel.voyager.navigator.tab.TabOptions
import com.readrops.app.compose.BuildConfig
import com.readrops.app.compose.R
import com.readrops.app.compose.util.components.SelectableIconText
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.spacing
object MoreTab : Tab {
override val options: TabOptions
@Composable
get() = TabOptions(
index = 4u,
title = "More"
)
@Composable
override fun Content() {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize()
) {
LargeSpacer()
Image(
painter = painterResource(id = R.drawable.ic_freshrss),
contentDescription = null,
modifier = Modifier.size(64.dp)
)
MediumSpacer()
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge
)
ShortSpacer()
Text(
text = "v${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.labelLarge
)
LargeSpacer()
SelectableIconText(
icon = painterResource(id = R.drawable.ic_settings),
text = stringResource(R.string.settings),
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
onClick = { }
)
SelectableIconText(
icon = painterResource(id = R.drawable.ic_settings),
text = "Backup",
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
onClick = { }
)
SelectableIconText(
icon = painterResource(id = R.drawable.ic_settings),
text = "Open-source libraries",
style = MaterialTheme.typography.titleMedium,
spacing = MaterialTheme.spacing.mediumSpacing,
onClick = { }
)
}
}
}

View File

@ -0,0 +1,83 @@
package com.readrops.app.compose.repositories
import com.readrops.api.services.SyncResult
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
data class ErrorResult(
val values: Map<Feed, Exception>
)
abstract class ARepository(
val database: Database,
val account: Account
) {
/**
* This method is intended for remote accounts.
*/
abstract suspend fun login()
/**
* Global synchronization for the local account.
* @param selectedFeeds feeds to be updated
* @param onUpdate get synchronization status
* @return returns the result of the synchronization used by notifications
* and errors per feed if occurred to be transmitted to the user
*/
abstract suspend fun synchronize(
selectedFeeds: List<Feed>?,
onUpdate: (Feed) -> Unit
): Pair<SyncResult, ErrorResult>
/**
* Global synchronization for remote accounts. Unlike the local account, remote accounts
* won't benefit from synchronization status and granular synchronization
*/
abstract suspend fun synchronize(): SyncResult
abstract suspend fun insertNewFeeds(urls: List<String>)
}
abstract class BaseRepository(
database: Database,
account: Account,
) : ARepository(database, account) {
open suspend fun updateFeed(feed: Feed) = database.newFeedDao().updateFeedFields(feed.id, feed.name!!, feed.url!!, feed.folderId)
open suspend fun deleteFeed(feed: Feed) = database.newFeedDao().delete(feed)
open suspend fun addFolder(folder: Folder) = database.newFolderDao().insert(folder)
open suspend fun updateFolder(folder: Folder) = database.newFolderDao().update(folder)
open suspend fun deleteFolder(folder: Folder) = database.newFolderDao().delete(folder)
open suspend fun setItemReadState(item: Item) {
database.newItemDao().updateReadState(item.id, item.isRead)
}
open suspend fun setItemStarState(item: Item) {
database.newItemDao().updateStarState(item.id, item.isStarred)
}
open suspend fun setAllItemsRead(accountId: Int) {
database.newItemDao().setAllItemsRead(accountId)
}
open suspend fun setAllStarredItemsRead(accountId: Int) {
database.newItemDao().setAllStarredItemsRead(accountId)
}
open suspend fun setAllItemsReadByFeed(feedId: Int, accountId: Int) {
database.newItemDao().setAllItemsReadByFeed(feedId, accountId)
}
open suspend fun setAllItemsReadByFolder(folderId: Int, accountId: Int) {
database.newItemDao().setAllItemsReadByFolder(folderId, accountId)
}
}

View File

@ -0,0 +1,57 @@
package com.readrops.app.compose.repositories
import com.readrops.db.Database
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
class GetFoldersWithFeeds(
private val database: Database,
) {
fun get(accountId: Int): Flow<Map<Folder?, List<Feed>>> {
return combine(
flow = database.newFolderDao()
.selectFoldersAndFeeds(accountId),
flow2 = database.newFeedDao()
.selectFeedsWithoutFolder(accountId)
) { folders, feedsWithoutFolder ->
val foldersWithFeeds = folders.groupBy(
keySelector = {
Folder(
id = it.folderId,
name = it.folderName,
accountId = it.accountId
) as Folder?
},
valueTransform = {
Feed(
id = it.feedId,
name = it.feedName,
iconUrl = it.feedIcon,
url = it.feedUrl,
siteUrl = it.feedSiteUrl,
unreadCount = it.unreadCount
)
}
).mapValues { listEntry ->
if (listEntry.value.any { it.id == 0 }) {
listOf()
} else {
listEntry.value
}
}
if (feedsWithoutFolder.isNotEmpty()) {
foldersWithFeeds + mapOf(
Pair(
null,
feedsWithoutFolder.map { it.feed.apply { unreadCount = it.unreadCount } })
)
} else {
foldersWithFeeds
}
}
}
}

View File

@ -0,0 +1,124 @@
package com.readrops.app.compose.repositories
import com.readrops.api.localfeed.LocalRSSDataSource
import com.readrops.api.services.SyncResult
import com.readrops.api.utils.ApiUtils
import com.readrops.api.utils.HtmlParser
import com.readrops.app.compose.util.FeedColors
import com.readrops.app.compose.util.Utils
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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Headers
import org.jsoup.Jsoup
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
class LocalRSSRepository(
private val dataSource: LocalRSSDataSource,
database: Database,
account: Account
) : BaseRepository(database, account), KoinComponent {
override suspend fun login() { /* useless here */
}
override suspend fun synchronize(
selectedFeeds: List<Feed>?,
onUpdate: (Feed) -> Unit
): Pair<SyncResult, ErrorResult> {
val errors = mutableMapOf<Feed, Exception>()
val syncResult = SyncResult()
val feeds = if (selectedFeeds.isNullOrEmpty()) {
database.newFeedDao().selectFeeds(account.id)
} else selectedFeeds
for (feed in feeds) {
onUpdate(feed)
val headers = Headers.Builder()
if (feed.etag != null) {
headers[ApiUtils.IF_NONE_MATCH_HEADER] = feed.etag!!
}
if (feed.lastModified != null) {
headers[ApiUtils.IF_MODIFIED_HEADER] = feed.lastModified!!
}
try {
val pair = dataSource.queryRSSResource(feed.url!!, headers.build())
pair?.let {
insertNewItems(it.second, feed)
syncResult.items = it.second
}
} catch (e: Exception) {
errors[feed] = e
}
}
return Pair(syncResult, ErrorResult(errors))
}
override suspend fun synchronize(): SyncResult =
throw NotImplementedError("This method can't be called here")
override suspend fun insertNewFeeds(urls: List<String>) = withContext(Dispatchers.IO) {
for (url in urls) {
try {
val result = dataSource.queryRSSResource(url, null)!!
insertFeed(result.first)
} catch (e: Exception) {
throw e
}
}
}
private suspend fun insertNewItems(items: List<Item>, feed: Feed) {
items.sortedWith(Item::compareTo) // TODO Check if ordering is useful in this situation
val itemsToInsert = mutableListOf<Item>()
for (item in items) {
if (!database.itemDao().itemExists(item.guid!!, feed.accountId)) {
if (item.description != null) {
item.cleanDescription = Jsoup.parse(item.description).text()
}
if (item.content != null) {
item.readTime = Utils.readTimeFromString(item.content!!)
} else if (item.description != null) {
item.readTime = Utils.readTimeFromString(item.cleanDescription!!)
}
item.feedId = feed.id
itemsToInsert += item
}
}
database.newItemDao().insert(itemsToInsert)
}
private suspend fun insertFeed(feed: Feed): Feed {
require(!database.newFeedDao().feedExists(feed.url!!, account.id)) {
"Feed already exists for account ${account.accountName}"
}
return feed.apply {
accountId = account.id
// we need empty headers to query the feed just after, without any 304 result
etag = null
lastModified = null
iconUrl = HtmlParser.getFaviconLink(siteUrl!!, get()).also { feedUrl ->
feedUrl?.let { backgroundColor = FeedColors.getFeedColor(it) }
}
id = database.newFeedDao().insert(this).toInt()
}
}
}

View File

@ -0,0 +1,93 @@
package com.readrops.app.compose.timelime
import androidx.compose.foundation.clickable
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.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.readrops.app.compose.R
import com.readrops.app.compose.util.theme.LargeSpacer
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.filters.ListSortType
import com.readrops.db.queries.QueryFilters
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FilterBottomSheet(
viewModel: TimelineViewModel,
filters: QueryFilters,
onDismiss: () -> Unit,
) {
ModalBottomSheet(
onDismissRequest = onDismiss
) {
Column(
modifier = Modifier.padding(MaterialTheme.spacing.mediumSpacing)
) {
Text(
text = stringResource(R.string.filters)
)
ShortSpacer()
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { viewModel.setShowReadItemsState(!filters.showReadItems) }
) {
Checkbox(
checked = filters.showReadItems,
onCheckedChange = { viewModel.setShowReadItemsState(!filters.showReadItems) }
)
ShortSpacer()
Text(
text = stringResource(R.string.show_read_articles)
)
}
ShortSpacer()
fun setSortTypeState() {
viewModel.setSortTypeState(
if (filters.sortType == ListSortType.NEWEST_TO_OLDEST)
ListSortType.OLDEST_TO_NEWEST
else
ListSortType.NEWEST_TO_OLDEST
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clickable { setSortTypeState() }
) {
Checkbox(
checked = filters.sortType == ListSortType.OLDEST_TO_NEWEST,
onCheckedChange = { setSortTypeState() }
)
ShortSpacer()
Text(
text = "Show oldest items first"
)
}
LargeSpacer()
}
}
}

View File

@ -0,0 +1,205 @@
package com.readrops.app.compose.timelime
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.outlined.FavoriteBorder
import androidx.compose.material.icons.outlined.Share
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.readrops.api.utils.DateUtils
import com.readrops.app.compose.R
import com.readrops.app.compose.util.components.IconText
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.VeryShortSpacer
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.pojo.ItemWithFeed
import kotlin.math.roundToInt
@Composable
fun TimelineItem(
itemWithFeed: ItemWithFeed,
onClick: () -> Unit,
onFavorite: () -> Unit,
onReadLater: () -> Unit,
onShare: () -> Unit,
modifier: Modifier = Modifier,
compactLayout: Boolean = false,
) {
Card(
modifier = modifier
.padding(horizontal = MaterialTheme.spacing.shortSpacing)
.alpha(if (itemWithFeed.item.isRead) 0.6f else 1f)
.clickable { onClick() }
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(
start = MaterialTheme.spacing.shortSpacing,
end = MaterialTheme.spacing.shortSpacing,
top = MaterialTheme.spacing.shortSpacing,
)
) {
Column(
modifier = Modifier.weight(1f)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
AsyncImage(
model = itemWithFeed.feedIconUrl,
contentDescription = null,
placeholder = painterResource(R.drawable.ic_rss_feed_grey),
modifier = Modifier.size(24.dp)
)
VeryShortSpacer()
Text(
text = itemWithFeed.feedName,
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (itemWithFeed.bgColor != 0) Color(itemWithFeed.bgColor) else MaterialTheme.colorScheme.onSurface,
)
}
}
Row {
Surface(
color = if (itemWithFeed.bgColor != 0) Color(itemWithFeed.bgColor) else MaterialTheme.colorScheme.primary,
shape = RoundedCornerShape(48.dp)
) {
Text(
text = DateUtils.formattedDateByLocal(itemWithFeed.item.pubDate!!),
style = MaterialTheme.typography.labelMedium,
color = if (itemWithFeed.bgColor != 0) Color.White else MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.padding(
horizontal = MaterialTheme.spacing.shortSpacing,
vertical = MaterialTheme.spacing.veryShortSpacing
)
)
}
}
}
ShortSpacer()
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
modifier = Modifier
.fillMaxWidth()
.padding(start = MaterialTheme.spacing.shortSpacing)
) {
if (itemWithFeed.folder != null) {
IconText(
icon = painterResource(id = R.drawable.ic_folder_grey),
text = itemWithFeed.folder!!.name!!,
style = MaterialTheme.typography.labelMedium
)
Text(
text = "·",
style = MaterialTheme.typography.labelMedium,
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.veryShortSpacing)
)
}
IconText(
icon = painterResource(id = R.drawable.ic_hourglass_empty),
text = if (itemWithFeed.item.readTime < 1) "> 1 min" else "${itemWithFeed.item.readTime.roundToInt()} mins",
style = MaterialTheme.typography.labelMedium
)
}
ShortSpacer()
Text(
text = itemWithFeed.item.title!!,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.shortSpacing)
)
ShortSpacer()
if (itemWithFeed.item.cleanDescription != null && !compactLayout) {
Text(
text = itemWithFeed.item.cleanDescription!!,
style = MaterialTheme.typography.bodyMedium,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = MaterialTheme.spacing.shortSpacing)
)
ShortSpacer()
}
if (itemWithFeed.item.hasImage && !compactLayout) {
AsyncImage(
model = itemWithFeed.item.imageLink,
contentDescription = itemWithFeed.item.title!!,
contentScale = ContentScale.Crop,
modifier = Modifier
.aspectRatio(16f / 9f)
.fillMaxWidth()
)
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.padding(MaterialTheme.spacing.shortSpacing)
) {
Icon(
imageVector = if (itemWithFeed.item.isStarred) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = null,
modifier = Modifier.clickable { onFavorite() }
)
Icon(
painter = painterResource(id = R.drawable.ic_read_later),
contentDescription = null,
modifier = Modifier.clickable { onReadLater() }
)
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = null,
modifier = Modifier.clickable { onShare() }
)
}
}
}
}

View File

@ -0,0 +1,266 @@
package com.readrops.app.compose.timelime
import android.util.Log
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
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.google.accompanist.swiperefresh.SwipeRefresh
import com.google.accompanist.swiperefresh.rememberSwipeRefreshState
import com.readrops.app.compose.R
import com.readrops.app.compose.item.ItemScreen
import com.readrops.app.compose.timelime.drawer.TimelineDrawer
import com.readrops.app.compose.util.components.CenteredColumn
import com.readrops.app.compose.util.components.TwoChoicesDialog
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.filters.FilterType
import org.koin.androidx.compose.getViewModel
object TimelineTab : Tab {
override val options: TabOptions
@Composable
get() = TabOptions(
index = 1u,
title = "Timeline",
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
override fun Content() {
val viewModel = getViewModel<TimelineViewModel>()
val state by viewModel.timelineState.collectAsStateWithLifecycle()
val items = state.itemState.collectAsLazyPagingItems()
val navigator = LocalNavigator.currentOrThrow
val context = LocalContext.current
val scrollState = rememberLazyListState()
// Use the depreciated refresh swipe as the material 3 one isn't available yet
val swipeState = rememberSwipeRefreshState(state.isRefreshing)
val drawerState = rememberDrawerState(
initialValue = DrawerValue.Closed,
confirmStateChange = {
if (it == DrawerValue.Closed) {
viewModel.closeDrawer()
} else {
viewModel.openDrawer()
}
true
}
)
BackHandler(
enabled = state.isDrawerOpen,
onBack = { viewModel.closeDrawer() }
)
LaunchedEffect(state.isDrawerOpen) {
if (state.isDrawerOpen) {
drawerState.open()
} else {
drawerState.close()
}
}
when (state.dialog) {
DialogState.ConfirmDialog -> {
TwoChoicesDialog(
title = "Mark all items as read",
text = "Do you really want to mark all items as read?",
icon = painterResource(id = R.drawable.ic_rss_feed_grey),
confirmText = "Validate",
dismissText = "Cancel",
onDismiss = { viewModel.closeDialog() },
onConfirm = {
viewModel.closeDialog()
viewModel.setAllItemsRead()
}
)
}
DialogState.FilterSheet -> {
FilterBottomSheet(
viewModel = viewModel,
filters = state.filters,
onDismiss = {
viewModel.closeDialog()
}
)
}
null -> {}
}
ModalNavigationDrawer(
drawerState = drawerState,
drawerContent = {
TimelineDrawer(
state = state,
onClickDefaultItem = {
viewModel.updateDrawerDefaultItem(it)
},
onFolderClick = {
viewModel.updateDrawerFolderSelection(it)
},
onFeedClick = {
viewModel.updateDrawerFeedSelection(it)
}
)
}
) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = when (state.filters.filterType) {
FilterType.FEED_FILTER -> state.filterFeedName
FilterType.FOLDER_FILER -> state.filterFolderName
FilterType.READ_IT_LATER_FILTER -> stringResource(R.string.read_later)
FilterType.STARS_FILTER -> stringResource(R.string.favorites)
FilterType.NO_FILTER -> stringResource(R.string.articles)
FilterType.NEW -> stringResource(R.string.new_articles)
}
)
},
navigationIcon = {
IconButton(
onClick = { viewModel.openDrawer() }
) {
Icon(
imageVector = Icons.Default.Menu,
contentDescription = null
)
}
},
actions = {
IconButton(
onClick = { viewModel.openDialog(DialogState.FilterSheet) }
) {
Icon(
painter = painterResource(id = R.drawable.ic_filter_list),
contentDescription = null
)
}
IconButton(
onClick = { viewModel.refreshTimeline() }
) {
Icon(
painter = painterResource(id = R.drawable.ic_sync),
contentDescription = null
)
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = {
if (state.filters.filterType == FilterType.NO_FILTER) {
viewModel.openDialog(DialogState.ConfirmDialog)
} else {
viewModel.setAllItemsRead()
}
}
) {
Icon(
painter = painterResource(id = R.drawable.ic_done_all),
contentDescription = null
)
}
},
) { paddingValues ->
SwipeRefresh(
state = swipeState,
onRefresh = { viewModel.refreshTimeline() },
modifier = Modifier.padding(paddingValues)
) {
when {
items.isLoading() -> {
Log.d("TAG", "loading")
CenteredColumn {
CircularProgressIndicator()
}
}
items.isError() -> Text(text = "error")
else -> {
LazyColumn(
state = scrollState,
contentPadding = PaddingValues(vertical = MaterialTheme.spacing.shortSpacing),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.shortSpacing)
) {
items(
count = items.itemCount,
//key = { items[it]!! },
contentType = { "item_with_feed" }
) { itemCount ->
val itemWithFeed = items[itemCount]!!
TimelineItem(
itemWithFeed = itemWithFeed,
onClick = {
viewModel.setItemRead(itemWithFeed.item)
navigator.push(ItemScreen())
},
onFavorite = { viewModel.updateStarState(itemWithFeed.item) },
onReadLater = {},
onShare = {
viewModel.shareItem(itemWithFeed.item, context)
},
compactLayout = true
)
}
}
}
}
}
}
}
}
}
fun <T : Any> LazyPagingItems<T>.isLoading(): Boolean {
return loadState.append is LoadState.Loading //|| loadState.refresh is LoadState.Loading
}
fun <T : Any> LazyPagingItems<T>.isError(): Boolean {
return loadState.append is LoadState.Error //|| loadState.refresh is LoadState.Error
}

View File

@ -0,0 +1,255 @@
package com.readrops.app.compose.timelime
import android.content.Context
import android.content.Intent
import androidx.compose.runtime.Immutable
import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.cachedIn
import com.readrops.app.compose.base.TabViewModel
import com.readrops.app.compose.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.filters.FilterType
import com.readrops.db.filters.ListSortType
import com.readrops.db.pojo.ItemWithFeed
import com.readrops.db.queries.ItemsQueryBuilder
import com.readrops.db.queries.QueryFilters
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class TimelineViewModel(
private val database: Database,
private val getFoldersWithFeeds: GetFoldersWithFeeds,
private val dispatcher: CoroutineDispatcher = Dispatchers.IO
) : TabViewModel(database) {
private val _timelineState = MutableStateFlow(TimelineState())
val timelineState = _timelineState.asStateFlow()
private val filters = MutableStateFlow(_timelineState.value.filters)
init {
viewModelScope.launch(dispatcher) {
combine(
accountEvent,
filters
) { account, filters ->
filters.accountId = account.id
Pair(account, filters)
}.collectLatest { (account, filters) ->
val query = ItemsQueryBuilder.buildItemsQuery(filters)
_timelineState.update {
it.copy(
itemState = Pager(
config = PagingConfig(
pageSize = 10,
prefetchDistance = 10
),
pagingSourceFactory = {
database.newItemDao().selectAll(query)
},
).flow
.cachedIn(viewModelScope)
)
}
getFoldersWithFeeds.get(account.id)
.collect { foldersAndFeeds ->
_timelineState.update {
it.copy(
foldersAndFeeds = foldersAndFeeds
)
}
}
}
}
}
fun refreshTimeline() {
_timelineState.update { it.copy(isRefreshing = true) }
viewModelScope.launch(dispatcher) {
repository?.synchronize(null) {
}
_timelineState.update {
it.copy(
isRefreshing = false,
endSynchronizing = true
)
}
}
}
fun openDrawer() {
_timelineState.update { it.copy(isDrawerOpen = true) }
}
fun closeDrawer() {
_timelineState.update { it.copy(isDrawerOpen = false) }
}
fun updateDrawerDefaultItem(selection: FilterType) {
_timelineState.update {
it.copy(
filters = updateFilters {
it.filters.copy(
filterType = selection
)
},
isDrawerOpen = false
)
}
}
fun updateDrawerFolderSelection(folder: Folder) {
_timelineState.update {
it.copy(
filters = updateFilters {
it.filters.copy(
filterType = FilterType.FOLDER_FILER,
filterFolderId = folder.id,
filterFeedId = 0
)
},
filterFolderName = folder.name!!,
isDrawerOpen = false
)
}
}
fun updateDrawerFeedSelection(feed: Feed) {
_timelineState.update {
it.copy(
filters = updateFilters {
it.filters.copy(
filterType = FilterType.FEED_FILTER,
filterFeedId = feed.id,
filterFolderId = 0
)
},
filterFeedName = feed.name!!,
isDrawerOpen = false
)
}
}
private fun updateFilters(block: () -> QueryFilters): QueryFilters {
val filter = block()
filters.update { filter }
return filter
}
fun setItemRead(item: Item) {
item.isRead = true
updateItemReadState(item)
}
private fun updateItemReadState(item: Item) {
viewModelScope.launch(dispatcher) {
repository?.setItemReadState(item)
}
}
fun updateStarState(item: Item) {
viewModelScope.launch(dispatcher) {
with(item) {
isStarred = isStarred.not()
repository?.setItemStarState(this)
}
}
}
fun shareItem(item: Item, context: Context) {
Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, item.link)
}.also {
context.startActivity(Intent.createChooser(it, null))
}
}
fun setAllItemsRead() {
viewModelScope.launch(dispatcher) {
when (_timelineState.value.filters.filterType) {
FilterType.FEED_FILTER ->
repository?.setAllItemsReadByFeed(
_timelineState.value.filters.filterFeedId,
currentAccount!!.id
)
FilterType.FOLDER_FILER -> repository?.setAllItemsReadByFolder(
_timelineState.value.filters.filterFolderId,
currentAccount!!.id
)
FilterType.READ_IT_LATER_FILTER -> TODO()
FilterType.STARS_FILTER -> repository?.setAllStarredItemsRead(currentAccount!!.id)
FilterType.NO_FILTER -> repository?.setAllItemsRead(currentAccount!!.id)
FilterType.NEW -> TODO()
}
}
}
fun openDialog(dialog: DialogState) = _timelineState.update { it.copy(dialog = dialog) }
fun closeDialog() = _timelineState.update { it.copy(dialog = null) }
fun setShowReadItemsState(showReadItems: Boolean) {
_timelineState.update {
it.copy(
filters = updateFilters {
it.filters.copy(
showReadItems = showReadItems
)
}
)
}
}
fun setSortTypeState(sortType: ListSortType) {
_timelineState.update {
it.copy(
filters = updateFilters {
it.filters.copy(
sortType = sortType
)
}
)
}
}
}
@Immutable
data class TimelineState(
val isRefreshing: Boolean = false,
val isDrawerOpen: Boolean = false,
val endSynchronizing: Boolean = false,
val filters: QueryFilters = QueryFilters(),
val filterFeedName: String = "",
val filterFolderName: String = "",
val foldersAndFeeds: Map<Folder?, List<Feed>> = emptyMap(),
val itemState: Flow<PagingData<ItemWithFeed>> = emptyFlow(),
val dialog: DialogState? = null
)
sealed interface DialogState {
object ConfirmDialog : DialogState
object FilterSheet : DialogState
}

View File

@ -0,0 +1,59 @@
package com.readrops.app.compose.timelime.drawer
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.readrops.app.compose.util.theme.DrawerSpacing
@Composable
fun DrawerFeedItem(
label: @Composable () -> Unit,
icon: @Composable () -> Unit,
badge: @Composable () -> Unit,
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val colors = NavigationDrawerItemDefaults.colors()
Surface(
selected = selected,
onClick = onClick,
color = colors.containerColor(selected = selected).value,
shape = CircleShape,
modifier = modifier
.height(36.dp)
.fillMaxWidth()
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 24.dp)
) {
val iconColor = colors.iconColor(selected).value
CompositionLocalProvider(LocalContentColor provides iconColor, content = icon)
DrawerSpacing()
Box(Modifier.weight(1f)) {
val labelColor = colors.textColor(selected).value
CompositionLocalProvider(LocalContentColor provides labelColor, content = label)
}
DrawerSpacing()
val badgeColor = colors.badgeColor(selected).value
CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge)
}
}
}

View File

@ -0,0 +1,140 @@
package com.readrops.app.compose.timelime.drawer
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.readrops.app.compose.R
import com.readrops.app.compose.util.theme.DrawerSpacing
import com.readrops.db.entities.Feed
@Composable
fun DrawerFolderItem(
label: @Composable () -> Unit,
icon: @Composable () -> Unit,
badge: @Composable () -> Unit,
selected: Boolean,
onClick: () -> Unit,
feeds: List<Feed>,
selectedFeed: Int,
onFeedClick: (Feed) -> Unit,
modifier: Modifier = Modifier,
) {
val colors = NavigationDrawerItemDefaults.colors()
var isExpanded by remember { mutableStateOf(false) }
val rotationState by animateFloatAsState(
targetValue = if (isExpanded) 180f else 0f,
label = "drawer item arrow rotation"
)
Column(
modifier = Modifier.animateContentSize(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing,
)
)
) {
Surface(
selected = selected,
onClick = onClick,
color = colors.containerColor(selected = selected).value,
shape = CircleShape,
modifier = modifier
.height(56.dp)
.fillMaxWidth()
.animateContentSize(
animationSpec = tween(
durationMillis = 300,
easing = LinearOutSlowInEasing,
)
)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(start = 16.dp, end = 24.dp)
) {
val iconColor = colors.iconColor(selected).value
CompositionLocalProvider(LocalContentColor provides iconColor, content = icon)
DrawerSpacing()
Box(Modifier.weight(1f)) {
val labelColor = colors.textColor(selected).value
CompositionLocalProvider(LocalContentColor provides labelColor, content = label)
}
DrawerSpacing()
val badgeColor = colors.badgeColor(selected).value
CompositionLocalProvider(LocalContentColor provides badgeColor, content = badge)
DrawerSpacing()
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier
.clickable { isExpanded = isExpanded.not() }
.rotate(rotationState),
)
}
}
if (isExpanded && feeds.isNotEmpty()) {
for (feed in feeds) {
DrawerFeedItem(
label = {
Text(
text = feed.name!!,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
icon = {
AsyncImage(
model = feed.iconUrl,
contentDescription = feed.name,
placeholder = painterResource(id = R.drawable.ic_folder_grey),
modifier = Modifier.size(24.dp)
)
},
badge = { Text(feed.unreadCount.toString()) },
selected = feed.id == selectedFeed,
onClick = { onFeedClick(feed) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
}
}

View File

@ -0,0 +1,184 @@
package com.readrops.app.compose.timelime.drawer
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemDefaults
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 androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.readrops.app.compose.R
import com.readrops.app.compose.timelime.TimelineState
import com.readrops.app.compose.util.theme.spacing
import com.readrops.db.entities.Feed
import com.readrops.db.entities.Folder
import com.readrops.db.filters.FilterType
@Composable
fun TimelineDrawer(
state: TimelineState,
onClickDefaultItem: (FilterType) -> Unit,
onFolderClick: (Folder) -> Unit,
onFeedClick: (Feed) -> Unit,
) {
val scrollState = rememberScrollState()
ModalDrawerSheet(
modifier = Modifier
.fillMaxHeight()
.verticalScroll(scrollState)
) {
Spacer(modifier = Modifier.size(MaterialTheme.spacing.drawerSpacing))
DrawerDefaultItems(
selectedItem = state.filters.filterType,
onClick = { onClickDefaultItem(it) }
)
DrawerDivider()
Column {
for (folderEntry in state.foldersAndFeeds) {
val folder = folderEntry.key
if (folder != null) {
DrawerFolderItem(
label = {
Text(
text = folder.name!!,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
icon = {
Icon(
painterResource(id = R.drawable.ic_folder_grey),
contentDescription = null
)
},
badge = {
Text(folderEntry.value.sumOf { it.unreadCount }.toString())
},
selected = state.filters.filterFolderId == folder.id,
onClick = { onFolderClick(folder) },
feeds = folderEntry.value,
selectedFeed = state.filters.filterFeedId,
onFeedClick = { onFeedClick(it) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
} else {
val feeds = folderEntry.value
for (feed in feeds) {
DrawerFeedItem(
label = {
Text(
text = feed.name!!,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
icon = {
AsyncImage(
model = feed.iconUrl,
contentDescription = feed.name,
placeholder = painterResource(id = R.drawable.ic_folder_grey),
modifier = Modifier.size(24.dp)
)
},
badge = { Text(feed.unreadCount.toString()) },
selected = feed.id == state.filters.filterFeedId,
onClick = { onFeedClick(feed) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
}
}
}
}
}
@Composable
fun DrawerDefaultItems(
selectedItem: FilterType,
onClick: (FilterType) -> Unit,
) {
NavigationDrawerItem(
label = { Text(text = stringResource(R.string.articles)) },
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_timeline),
contentDescription = null
)
},
selected = selectedItem == FilterType.NO_FILTER,
onClick = { onClick(FilterType.NO_FILTER) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
NavigationDrawerItem(
label = { Text("New articles") },
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_new),
contentDescription = null
)
},
selected = selectedItem == FilterType.NEW,
onClick = { onClick(FilterType.NEW) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
NavigationDrawerItem(
label = { Text(text = stringResource(R.string.favorites)) },
icon = {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null
)
},
selected = selectedItem == FilterType.STARS_FILTER,
onClick = { onClick(FilterType.STARS_FILTER) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
NavigationDrawerItem(
label = { Text(text = stringResource(R.string.read_later)) },
icon = {
Icon(
painter = painterResource(id = R.drawable.ic_read_later),
contentDescription = null
)
},
selected = selectedItem == FilterType.READ_IT_LATER_FILTER,
onClick = { onClick(FilterType.READ_IT_LATER_FILTER) },
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding)
)
}
@Composable
fun DrawerDivider() {
Divider(
thickness = 2.dp,
modifier = Modifier.padding(
vertical = MaterialTheme.spacing.drawerSpacing,
horizontal = 28.dp // M3 guidelines
)
)
}

View File

@ -0,0 +1,7 @@
package com.readrops.app.compose.util
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
fun TextStyle.toDp(): Dp = fontSize.value.dp

View File

@ -0,0 +1,48 @@
package com.readrops.app.compose.util
import android.content.Context
import android.graphics.drawable.BitmapDrawable
import androidx.annotation.ColorInt
import androidx.palette.graphics.Palette
import coil.imageLoader
import coil.request.ImageRequest
import org.koin.core.component.KoinComponent
import org.koin.core.component.get
object FeedColors : KoinComponent {
suspend fun getFeedColor(feedUrl: String): Int {
val context = get<Context>() // TODO maybe call imageLoader directly ? may require some DI changes
val result = context.imageLoader
.execute(
ImageRequest.Builder(context)
.data(feedUrl)
.allowHardware(false)
.build()
).drawable as BitmapDrawable
val palette = Palette.from(result.bitmap).generate()
val dominantSwatch = palette.dominantSwatch
return if (dominantSwatch != null && !isColorTooBright(dominantSwatch.rgb)
&& !isColorTooDark(dominantSwatch.rgb)) {
dominantSwatch.rgb
} else 0
}
private fun isColorTooBright(@ColorInt color: Int): Boolean {
return getColorLuma(color) > 210
}
private fun isColorTooDark(@ColorInt color: Int): Boolean {
return getColorLuma(color) < 40
}
private fun getColorLuma(@ColorInt color: Int): Double {
val r = color shr 16 and 0xff
val g = color shr 8 and 0xff
val b = color shr 0 and 0xff
return 0.2126 * r + 0.7152 * g + 0.0722 * b
}
}

View File

@ -0,0 +1,11 @@
package com.readrops.app.compose.util
object Utils {
private const val AVERAGE_WORDS_PER_MINUTE = 250
fun readTimeFromString(value: String): Double {
val nbWords = value.split("\\s+").size
return nbWords.toDouble() / AVERAGE_WORDS_PER_MINUTE
}
}

View File

@ -0,0 +1,63 @@
package com.readrops.app.compose.util.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import com.readrops.app.compose.util.theme.MediumSpacer
import com.readrops.app.compose.util.theme.spacing
@Composable
fun BaseDialog(
title: String,
icon: Painter,
onDismiss: () -> Unit,
content: @Composable () -> Unit
) {
Dialog(
onDismissRequest = onDismiss
) {
Card(
shape = RoundedCornerShape(24.dp)
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.background)
.padding(MaterialTheme.spacing.largeSpacing)
) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(MaterialTheme.spacing.largeSpacing)
)
MediumSpacer()
Text(
text = title,
style = MaterialTheme.typography.headlineSmall
)
MediumSpacer()
content()
}
}
}
}

View File

@ -0,0 +1,14 @@
package com.readrops.app.compose.util.components
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable
fun CenteredProgressIndicator(
modifier: Modifier = Modifier
) {
CenteredColumn {
CircularProgressIndicator(modifier)
}
}

View File

@ -0,0 +1,50 @@
package com.readrops.app.compose.util.components
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
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.text.style.TextAlign
import com.readrops.app.compose.R
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.theme.VeryShortSpacer
import com.readrops.app.compose.util.theme.spacing
@Composable
fun ErrorMessage(
exception: Exception?
) {
CenteredColumn {
Icon(
painter = painterResource(id = R.drawable.ic_error),
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(MaterialTheme.spacing.veryLargeSpacing)
)
ShortSpacer()
Text(
text = "An error occurred",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.error,
)
VeryShortSpacer()
if (exception != null) {
val name = exception.javaClass.simpleName
val message = exception.message
Text(
text = "$name: $message",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
}
}
}

View File

@ -0,0 +1,174 @@
package com.readrops.app.compose.util.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import com.readrops.app.compose.util.theme.spacing
import com.readrops.app.compose.util.toDp
@Composable
fun BaseText(
text: String,
style: TextStyle,
modifier: Modifier = Modifier,
color: Color = LocalContentColor.current,
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
onClick: (() -> Unit)? = null,
leftContent: @Composable () -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = if (onClick != null) modifier.clickable { onClick() } else modifier,
) {
leftContent()
Spacer(Modifier.width(spacing))
Text(
text = text,
style = style,
color = color,
)
}
}
@Composable
fun IconText(
icon: Painter,
text: String,
style: TextStyle,
modifier: Modifier = Modifier,
color: Color = LocalContentColor.current,
tint: Color = LocalContentColor.current,
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
onClick: (() -> Unit)? = null,
) {
BaseText(
text = text,
style = style,
color = color,
spacing = spacing,
modifier = modifier,
onClick = onClick
) {
Icon(
painter = icon,
tint = tint,
contentDescription = null,
modifier = Modifier.size(style.toDp()),
)
}
}
@Composable
fun ImageText(
image: Painter,
text: String,
style: TextStyle,
modifier: Modifier = Modifier,
color: Color = LocalContentColor.current,
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
imageSize: Dp = style.toDp(),
onClick: (() -> Unit)? = null
) {
BaseText(
text = text,
style = style,
color = color,
spacing = spacing,
modifier = modifier,
onClick = onClick
) {
Image(
painter = image,
contentDescription = null,
modifier = Modifier.size(imageSize),
)
}
}
@Composable
fun SelectableIconText(
icon: Painter,
text: String,
style: TextStyle,
onClick: () -> Unit,
modifier: Modifier = Modifier,
color: Color = LocalContentColor.current,
tint: Color = LocalContentColor.current,
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
padding: Dp = MaterialTheme.spacing.shortSpacing
) {
Box(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(padding)
) {
BaseText(
text = text,
style = style,
color = color,
spacing = spacing
) {
Icon(
painter = icon,
tint = tint,
contentDescription = null,
modifier = Modifier.size(style.toDp()),
)
}
}
}
@Composable
fun SelectableImageText(
image: Painter,
text: String,
style: TextStyle,
onClick: () -> Unit,
modifier: Modifier = Modifier,
color: Color = LocalContentColor.current,
spacing: Dp = MaterialTheme.spacing.veryShortSpacing,
padding: Dp = MaterialTheme.spacing.shortSpacing,
imageSize: Dp = style.toDp()
) {
Box(
modifier = modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(padding)
) {
BaseText(
text = text,
style = style,
color = color,
spacing = spacing
) {
Image(
painter = image,
contentDescription = null,
modifier = Modifier.size(imageSize),
)
}
}
}

View File

@ -0,0 +1,50 @@
package com.readrops.app.compose.util.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.painter.Painter
import com.readrops.app.compose.util.theme.ShortSpacer
import com.readrops.app.compose.util.toDp
@Composable
fun CenteredColumn(
content: @Composable () -> Unit
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize()
) {
content()
}
}
@Composable
fun Placeholder(
text: String,
painter: Painter,
) {
CenteredColumn {
Icon(
painter = painter,
contentDescription = null,
modifier = Modifier.size(MaterialTheme.typography.displayMedium.toDp() * 1.5f)
)
ShortSpacer()
Text(
text = text,
style = MaterialTheme.typography.displaySmall
)
}
}

View File

@ -0,0 +1,24 @@
package com.readrops.app.compose.util.components
import androidx.compose.runtime.Composable
sealed class TextFieldError {
object EmptyField : TextFieldError()
object BadUrl : TextFieldError()
object UnreachableUrl : TextFieldError()
object NoRSSFeed : TextFieldError()
object NoRSSUrl : TextFieldError()
@Composable
fun errorText(): String =
// TODO replace by string resources
when (this) {
BadUrl -> "Input is not a valid URL"
EmptyField -> "Field can't be empty"
NoRSSFeed -> "No RSS feed found"
NoRSSUrl -> "The provided URL is not a valid RSS feed"
UnreachableUrl -> "Unreachable URL"
}
}

View File

@ -0,0 +1,42 @@
package com.readrops.app.compose.util.components
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
@Composable
fun TwoChoicesDialog(
title: String,
text: String,
icon: Painter,
confirmText: String,
dismissText: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
painter = icon,
contentDescription = null,
)
},
title = { Text(text = title) },
text = { Text(text = text) },
confirmButton = {
TextButton(onClick = onConfirm) {
Text(text = confirmText)
}
},
dismissButton = {
TextButton(onClick = onDismiss
) {
Text(text = dismissText)
}
},
)
}

View File

@ -0,0 +1,68 @@
package com.readrops.app.compose.util.theme
import androidx.compose.ui.graphics.Color
val md_theme_light_primary = Color(0xFF0062A2)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFD1E4FF)
val md_theme_light_onPrimaryContainer = Color(0xFF001D35)
val md_theme_light_secondary = Color(0xFFA43D00)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFFFDBCD)
val md_theme_light_onSecondaryContainer = Color(0xFF360F00)
val md_theme_light_tertiary = Color(0xFF006D3D)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFF97F7B7)
val md_theme_light_onTertiaryContainer = Color(0xFF00210F)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFF8FDFF)
val md_theme_light_onBackground = Color(0xFF001F25)
val md_theme_light_surface = Color(0xFFF8FDFF)
val md_theme_light_onSurface = Color(0xFF001F25)
val md_theme_light_surfaceVariant = Color(0xFFDFE2EB)
val md_theme_light_onSurfaceVariant = Color(0xFF42474E)
val md_theme_light_outline = Color(0xFF73777F)
val md_theme_light_inverseOnSurface = Color(0xFFD6F6FF)
val md_theme_light_inverseSurface = Color(0xFF00363F)
val md_theme_light_inversePrimary = Color(0xFF9DCAFF)
val md_theme_light_shadow = Color(0xFF000000)
val md_theme_light_surfaceTint = Color(0xFF0062A2)
val md_theme_light_outlineVariant = Color(0xFFC3C7CF)
val md_theme_light_scrim = Color(0xFF000000)
val md_theme_dark_primary = Color(0xFF9DCAFF)
val md_theme_dark_onPrimary = Color(0xFF003257)
val md_theme_dark_primaryContainer = Color(0xFF00497C)
val md_theme_dark_onPrimaryContainer = Color(0xFFD1E4FF)
val md_theme_dark_secondary = Color(0xFFFFB597)
val md_theme_dark_onSecondary = Color(0xFF581D00)
val md_theme_dark_secondaryContainer = Color(0xFF7D2D00)
val md_theme_dark_onSecondaryContainer = Color(0xFFFFDBCD)
val md_theme_dark_tertiary = Color(0xFF7BDA9C)
val md_theme_dark_onTertiary = Color(0xFF00391D)
val md_theme_dark_tertiaryContainer = Color(0xFF00522C)
val md_theme_dark_onTertiaryContainer = Color(0xFF97F7B7)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF001F25)
val md_theme_dark_onBackground = Color(0xFFA6EEFF)
val md_theme_dark_surface = Color(0xFF001F25)
val md_theme_dark_onSurface = Color(0xFFA6EEFF)
val md_theme_dark_surfaceVariant = Color(0xFF42474E)
val md_theme_dark_onSurfaceVariant = Color(0xFFC3C7CF)
val md_theme_dark_outline = Color(0xFF8D9199)
val md_theme_dark_inverseOnSurface = Color(0xFF001F25)
val md_theme_dark_inverseSurface = Color(0xFFA6EEFF)
val md_theme_dark_inversePrimary = Color(0xFF0062A2)
val md_theme_dark_shadow = Color(0xFF000000)
val md_theme_dark_surfaceTint = Color(0xFF9DCAFF)
val md_theme_dark_outlineVariant = Color(0xFF42474E)
val md_theme_dark_scrim = Color(0xFF000000)
val seed = Color(0xFF0072BC)

View File

@ -0,0 +1,45 @@
package com.readrops.app.compose.util.theme
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
data class Spacing(
val veryShortSpacing: Dp = 4.dp,
val shortSpacing: Dp = 8.dp,
val mediumSpacing: Dp = 16.dp,
val largeSpacing: Dp = 24.dp,
val veryLargeSpacing: Dp = 48.dp,
val drawerSpacing: Dp = 12.dp
)
val LocalSpacing = compositionLocalOf { Spacing() }
val MaterialTheme.spacing
@Composable
@ReadOnlyComposable
get() = LocalSpacing.current
@Composable
fun VeryShortSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.veryShortSpacing))
@Composable
fun ShortSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.shortSpacing))
@Composable
fun MediumSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.mediumSpacing))
@Composable
fun LargeSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.largeSpacing))
@Composable
fun VeryLargeSpacer() = Spacer(Modifier.size(MaterialTheme.spacing.veryLargeSpacing))
@Composable
fun DrawerSpacing() = Spacer(Modifier.size(MaterialTheme.spacing.drawerSpacing))

View File

@ -0,0 +1,90 @@
package com.readrops.app.compose.util.theme
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
@Composable
fun ReadropsTheme(
useDarkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (!useDarkTheme) {
LightColors
} else {
DarkColors
}
MaterialTheme(
colorScheme = colors,
content = content
)
}

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M13,8c0,-2.21 -1.79,-4 -4,-4S5,5.79 5,8s1.79,4 4,4S13,10.21 13,8zM15,10v2h3v3h2v-3h3v-2h-3V7h-2v3H15zM1,18v2h16v-2c0,-2.66 -5.33,-4 -8,-4S1,15.34 1,18z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M19.35,10.04C18.67,6.59 15.64,4 12,4 9.11,4 6.6,5.64 5.35,8.04 2.34,8.36 0,10.91 0,14c0,3.31 2.69,6 6,6h13c2.76,0 5,-2.24 5,-5 0,-2.64 -2.05,-4.78 -4.65,-4.96zM14,13v4h-4v-4H7l5,-5 5,5h-3z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,3c-4.97,0 -9,4.03 -9,9s4.03,9 9,9c0.83,0 1.5,-0.67 1.5,-1.5 0,-0.39 -0.15,-0.74 -0.39,-1.01 -0.23,-0.26 -0.38,-0.61 -0.38,-0.99 0,-0.83 0.67,-1.5 1.5,-1.5L16,16c2.76,0 5,-2.24 5,-5 0,-4.42 -4.03,-8 -9,-8zM6.5,12c-0.83,0 -1.5,-0.67 -1.5,-1.5S5.67,9 6.5,9 8,9.67 8,10.5 7.33,12 6.5,12zM9.5,8C8.67,8 8,7.33 8,6.5S8.67,5 9.5,5s1.5,0.67 1.5,1.5S10.33,8 9.5,8zM14.5,8c-0.83,0 -1.5,-0.67 -1.5,-1.5S13.67,5 14.5,5s1.5,0.67 1.5,1.5S15.33,8 14.5,8zM17.5,12c-0.83,0 -1.5,-0.67 -1.5,-1.5S16.67,9 17.5,9s1.5,0.67 1.5,1.5 -0.67,1.5 -1.5,1.5z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M18,7l-1.41,-1.41 -6.34,6.34 1.41,1.41L18,7zM22.24,5.59L11.66,16.17 7.48,12l-1.41,1.41L11.66,19l12,-12 -1.42,-1.41zM0.41,13.41L6,19l1.41,-1.41L1.83,12 0.41,13.41z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-2h2v2zM13,13h-2L11,7h2v6z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M10,18h4v-2h-4v2zM3,6v2h18L21,6L3,6zM6,13h12v-2L6,11v2z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#727272"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M6,2v6h0.01L6,8.01 10,12l-4,4 0.01,0.01L6,16.01L6,22h12v-5.99h-0.01L18,16l-4,-4 4,-3.99 -0.01,-0.01L18,8L18,2L6,2zM16,16.5L16,20L8,20v-3.5l4,-4 4,4zM12,11.5l-4,-4L8,4h8v3.5l-4,4z"/>
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#000000"
android:viewportHeight="24" android:viewportWidth="24"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="@android:color/white" android:pathData="M20,4H4C2.89,4 2.01,4.89 2.01,6L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2V6C22,4.89 21.11,4 20,4zM8.5,15H7.3l-2.55,-3.5V15H3.5V9h1.25l2.5,3.5V9H8.5V15zM13.5,10.26H11v1.12h2.5v1.26H11v1.11h2.5V15h-4V9h4V10.26zM20.5,14c0,0.55 -0.45,1 -1,1h-4c-0.55,0 -1,-0.45 -1,-1V9h1.25v4.51h1.13V9.99h1.25v3.51h1.12V9h1.25V14z"/>
</vector>

View File

@ -0,0 +1,5 @@
<vector android:height="24dp" android:tint="#FFFFFF"
android:viewportHeight="24.0" android:viewportWidth="24.0"
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000" android:pathData="M20,6h-8l-2,-2L4,4c-1.11,0 -1.99,0.89 -1.99,2L2,18c0,1.11 0.89,2 2,2h16c1.11,0 2,-0.89 2,-2L22,8c0,-1.11 -0.89,-2 -2,-2zM19,14h-3v3h-2v-3h-3v-2h3L14,9h2v3h3v2z"/>
</vector>

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