Support Fever API (#291)

This commit is contained in:
Ash 2023-01-01 07:57:38 +08:00 committed by GitHub
parent ba745e8ab1
commit 3642e22fbb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 1601 additions and 401 deletions

View File

@ -0,0 +1,371 @@
{
"formatVersion": 1,
"database": {
"version": 4,
"identityHash": "ff6225eee095fd62d3d3bff48aa0be8e",
"entities": [
{
"tableName": "account",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `type` INTEGER NOT NULL, `updateAt` INTEGER, `syncInterval` INTEGER NOT NULL DEFAULT 30, `syncOnStart` INTEGER NOT NULL DEFAULT 0, `syncOnlyOnWiFi` INTEGER NOT NULL DEFAULT 0, `syncOnlyWhenCharging` INTEGER NOT NULL DEFAULT 0, `keepArchived` INTEGER NOT NULL DEFAULT 2592000000, `syncBlockList` TEXT NOT NULL DEFAULT '', `securityKey` TEXT DEFAULT 'CvJ1PKM8EW8=')",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "type",
"columnName": "type",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updateAt",
"columnName": "updateAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "syncInterval",
"columnName": "syncInterval",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "30"
},
{
"fieldPath": "syncOnStart",
"columnName": "syncOnStart",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "syncOnlyOnWiFi",
"columnName": "syncOnlyOnWiFi",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "syncOnlyWhenCharging",
"columnName": "syncOnlyWhenCharging",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "keepArchived",
"columnName": "keepArchived",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "2592000000"
},
{
"fieldPath": "syncBlockList",
"columnName": "syncBlockList",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"fieldPath": "securityKey",
"columnName": "securityKey",
"affinity": "TEXT",
"notNull": false,
"defaultValue": "'CvJ1PKM8EW8='"
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `url` TEXT NOT NULL, `groupId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isNotification` INTEGER NOT NULL, `isFullContent` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`groupId`) REFERENCES `group`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "groupId",
"columnName": "groupId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isNotification",
"columnName": "isNotification",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isFullContent",
"columnName": "isFullContent",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_feed_groupId",
"unique": false,
"columnNames": [
"groupId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_groupId` ON `${TABLE_NAME}` (`groupId`)"
},
{
"name": "index_feed_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "group",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"groupId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "article",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `date` INTEGER NOT NULL, `title` TEXT NOT NULL, `author` TEXT, `rawDescription` TEXT NOT NULL, `shortDescription` TEXT NOT NULL, `fullContent` TEXT, `img` TEXT, `link` TEXT NOT NULL, `feedId` TEXT NOT NULL, `accountId` INTEGER NOT NULL, `isUnread` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `isReadLater` INTEGER NOT NULL, `updateAt` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`feedId`) REFERENCES `feed`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "date",
"columnName": "date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "author",
"columnName": "author",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "rawDescription",
"columnName": "rawDescription",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "shortDescription",
"columnName": "shortDescription",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "fullContent",
"columnName": "fullContent",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "img",
"columnName": "img",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "link",
"columnName": "link",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "feedId",
"columnName": "feedId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isUnread",
"columnName": "isUnread",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isStarred",
"columnName": "isStarred",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "isReadLater",
"columnName": "isReadLater",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "updateAt",
"columnName": "updateAt",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_article_feedId",
"unique": false,
"columnNames": [
"feedId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_article_feedId` ON `${TABLE_NAME}` (`feedId`)"
},
{
"name": "index_article_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_article_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": [
{
"table": "feed",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"feedId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "group",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_group_accountId",
"unique": false,
"columnNames": [
"accountId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_group_accountId` ON `${TABLE_NAME}` (`accountId`)"
}
],
"foreignKeys": []
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ff6225eee095fd62d3d3bff48aa0be8e')"
]
}
}

View File

@ -13,7 +13,7 @@ import kotlinx.coroutines.withContext
import me.ash.reader.data.module.ApplicationScope
import me.ash.reader.data.module.IODispatcher
import me.ash.reader.data.repository.*
import me.ash.reader.data.source.OpmlLocalDataSource
import me.ash.reader.data.source.OPMLDataSource
import me.ash.reader.data.source.RYDatabase
import me.ash.reader.data.source.RYNetworkDataSource
import me.ash.reader.ui.ext.del
@ -51,7 +51,7 @@ class RYApp : Application(), Configuration.Provider {
lateinit var ryNetworkDataSource: RYNetworkDataSource
@Inject
lateinit var opmlLocalDataSource: OpmlLocalDataSource
lateinit var OPMLDataSource: OPMLDataSource
@Inject
lateinit var rssHelper: RssHelper

View File

@ -276,6 +276,19 @@ interface ArticleDao {
isUnread: Boolean,
)
@Query(
"""
UPDATE article SET isStarred = :isStarred
WHERE id = :articleId
AND accountId = :accountId
"""
)
suspend fun markAsStarredByArticleId(
accountId: Int,
articleId: String,
isStarred: Boolean,
)
@Query(
"""
DELETE FROM article
@ -540,8 +553,14 @@ interface ArticleDao {
)
suspend fun queryById(id: String): ArticleWithFeed?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg article: Article)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertOnConflictIgnore(vararg article: Article)
@Insert
suspend fun insertList(articles: List<Article>): List<Long>
suspend fun insertList(articles: List<Article>)
@Update
suspend fun update(vararg article: Article)

View File

@ -87,8 +87,8 @@ interface FeedDao {
)
suspend fun queryByLink(accountId: Int, url: String): List<Feed>
@Insert
suspend fun insert(feed: Feed): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg feed: Feed)
@Insert
suspend fun insertList(feeds: List<Feed>): List<Long>

View File

@ -58,8 +58,8 @@ interface GroupDao {
)
suspend fun queryAll(accountId: Int): List<Group>
@Insert
suspend fun insert(group: Group): Long
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(vararg group: Group)
@Update
suspend fun update(vararg group: Group)

View File

@ -3,6 +3,7 @@ package me.ash.reader.data.model.account
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import me.ash.reader.data.model.account.security.DESUtils
import me.ash.reader.data.model.preference.*
import java.util.*
@ -32,4 +33,6 @@ data class Account(
var keepArchived: KeepArchivedPreference = KeepArchivedPreference.default,
@ColumnInfo(defaultValue = "")
var syncBlockList: SyncBlockList = SyncBlockListPreference.default,
@ColumnInfo(defaultValue = DESUtils.empty)
var securityKey: String? = DESUtils.empty,
)

View File

@ -0,0 +1,34 @@
package me.ash.reader.data.model.account.security
import android.util.Base64
import javax.crypto.Cipher
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.DESKeySpec
object DESUtils {
const val empty = "CvJ1PKM8EW8="
private const val secret = "mJn':4Nbk};AMVFGEWiY!(8&gp1xOv@/"
fun encrypt(cleartext: String): String {
val key = SecretKeyFactory
.getInstance("DES")
.generateSecret(DESKeySpec(secret.toByteArray()))
return Cipher.getInstance("DES").run {
init(Cipher.ENCRYPT_MODE, key)
Base64.encodeToString(doFinal(cleartext.toByteArray()), Base64.DEFAULT)
}
}
fun decrypt(ciphertext: String): String {
val key = SecretKeyFactory
.getInstance("DES")
.generateSecret(DESKeySpec(secret.toByteArray()))
return Cipher.getInstance("DES").run {
init(Cipher.DECRYPT_MODE, key)
String(doFinal(Base64.decode(ciphertext, Base64.DEFAULT)))
}
}
}

View File

@ -0,0 +1,22 @@
package me.ash.reader.data.model.account.security
class FeverSecurityKey private constructor() : SecurityKey() {
var serverUrl: String? = null
var username: String? = null
var password: String? = null
constructor(serverUrl: String?, username: String?, password: String?) : this() {
this.serverUrl = serverUrl
this.username = username
this.password = password
}
constructor(value: String? = DESUtils.empty) : this() {
decode(value, FeverSecurityKey::class.java).let {
serverUrl = it.serverUrl
username = it.username
password = it.password
}
}
}

View File

@ -0,0 +1,22 @@
package me.ash.reader.data.model.account.security
class GoogleReaderSecurityKey private constructor() : SecurityKey() {
var serverUrl: String? = null
var username: String? = null
var password: String? = null
constructor(serverUrl: String?, username: String?, password: String?) : this() {
this.serverUrl = serverUrl
this.username = username
this.password = password
}
constructor(value: String? = DESUtils.empty) : this() {
decode(value, GoogleReaderSecurityKey::class.java).let {
serverUrl = it.serverUrl
username = it.username
password = it.password
}
}
}

View File

@ -0,0 +1,10 @@
package me.ash.reader.data.model.account.security
class LocalSecurityKey private constructor() : SecurityKey() {
constructor(value: String? = DESUtils.empty) : this() {
decode(value, LocalSecurityKey::class.java).let {
}
}
}

View File

@ -0,0 +1,21 @@
package me.ash.reader.data.model.account.security
import com.google.gson.Gson
abstract class SecurityKey {
fun <T> decode(value: String?, classOfT: Class<T>): T =
Gson().fromJson(DESUtils.decrypt(value?.ifEmpty { DESUtils.empty } ?: DESUtils.empty), classOfT)
override fun toString(): String {
return DESUtils.encrypt(Gson().toJson(this))
}
override fun equals(other: Any?): Boolean {
return this.toString() == other.toString()
}
override fun hashCode(): Int {
return javaClass.hashCode()
}
}

View File

@ -4,8 +4,6 @@ import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import me.ash.reader.data.source.FeverApiDataSource
import me.ash.reader.data.source.GoogleReaderApiDataSource
import me.ash.reader.data.source.RYNetworkDataSource
import javax.inject.Singleton
@ -13,8 +11,6 @@ import javax.inject.Singleton
* Provides network requests for Retrofit.
*
* - [RYNetworkDataSource]: For network requests within the application
* - [FeverApiDataSource]: For network requests to the Fever API
* - [GoogleReaderApiDataSource]: For network requests to the Google Reader API
*/
@Module
@InstallIn(SingletonComponent::class)
@ -24,14 +20,4 @@ object RetrofitModule {
@Singleton
fun provideAppNetworkDataSource(): RYNetworkDataSource =
RYNetworkDataSource.getInstance()
@Provides
@Singleton
fun provideFeverApiDataSource(): FeverApiDataSource =
FeverApiDataSource.getInstance()
@Provides
@Singleton
fun provideGoogleReaderApiDataSource(): GoogleReaderApiDataSource =
GoogleReaderApiDataSource.getInstance()
}

View File

@ -0,0 +1,20 @@
package me.ash.reader.data.provider
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import me.ash.reader.data.module.UserAgentInterceptor
import me.ash.reader.data.module.cachingHttpClient
import okhttp3.OkHttpClient
abstract class BaseAPI {
protected val client: OkHttpClient = cachingHttpClient()
.newBuilder()
.addNetworkInterceptor(UserAgentInterceptor)
.build()
protected val gson: Gson = GsonBuilder().create()
protected inline fun <reified T> toDTO(jsonStr: String): T =
gson.fromJson(jsonStr, T::class.java)!!
}

View File

@ -0,0 +1,117 @@
package me.ash.reader.data.provider.fever
import me.ash.reader.data.provider.BaseAPI
import me.ash.reader.ui.ext.encodeBase64
import me.ash.reader.ui.ext.md5
import okhttp3.FormBody
import okhttp3.Request
import okhttp3.executeAsync
import java.util.concurrent.ConcurrentHashMap
class FeverAPI private constructor(
private val serverUrl: String,
private val apiKey: String,
private val httpUsername: String? = null,
private val httpPassword: String? = null,
) : BaseAPI() {
private suspend inline fun <reified T> postRequest(query: String?): T {
val response = client.newCall(
Request.Builder()
.apply {
if (httpUsername != null) {
addHeader("Authorization", "Basic ${"$httpUsername:$httpPassword".encodeBase64()}")
}
}
.url("$serverUrl?api=&${query ?: ""}")
.post(FormBody.Builder().add("api_key", apiKey).build())
.build())
.executeAsync()
when (response.code) {
401 -> throw Exception("Unauthorized")
!in 200..299 -> throw Exception("Forbidden")
}
return toDTO(response.body.string())
}
private fun checkAuth(authMap: Map<String, Any>): Int = checkAuth(authMap["auth"] as Int?)
private fun checkAuth(auth: Int?): Int = auth?.takeIf { it > 0 } ?: throw Exception("Unauthorized")
@Throws
suspend fun validCredentials(): Int = checkAuth(postRequest<FeverDTO.Common>(null).auth)
suspend fun getApiVersion(): Long =
postRequest<Map<String, Any>>(null)["api_version"] as Long?
?: throw Exception("Unable to get version")
suspend fun getGroups(): FeverDTO.Groups =
postRequest<FeverDTO.Groups>("groups").apply { checkAuth(auth) }
suspend fun getFeeds(): FeverDTO.Feeds =
postRequest<FeverDTO.Feeds>("feeds").apply { checkAuth(auth) }
suspend fun getFavicons(): FeverDTO.Favicons =
postRequest<FeverDTO.Favicons>("favicons").apply { checkAuth(auth) }
suspend fun getItems(): FeverDTO.Items =
postRequest<FeverDTO.Items>("items").apply { checkAuth(auth) }
suspend fun getItemsSince(id: String): FeverDTO.Items =
postRequest<FeverDTO.Items>("items&since_id=$id").apply { checkAuth(auth) }
suspend fun getItemsMax(id: String): FeverDTO.Items =
postRequest<FeverDTO.Items>("items&max_id=$id").apply { checkAuth(auth) }
suspend fun getItemsWith(ids: List<String>): FeverDTO.Items =
if (ids.size > 50) throw Exception("Too many ids")
else postRequest<FeverDTO.Items>("items&with_ids=${ids.joinToString(",")}").apply { checkAuth(auth) }
suspend fun getLinks(): FeverDTO.Links =
postRequest<FeverDTO.Links>("links").apply { checkAuth(auth) }
suspend fun getLinksWith(offset: Long, days: Long, page: Long): FeverDTO.Links =
postRequest<FeverDTO.Links>("links&offset=$offset&range=$days&page=$page").apply { checkAuth(auth) }
suspend fun getUnreadItems(): FeverDTO.ItemsByUnread =
postRequest<FeverDTO.ItemsByUnread>("unread_item_ids").apply { checkAuth(auth) }
suspend fun getSavedItems(): FeverDTO.ItemsByStarred =
postRequest<FeverDTO.ItemsByStarred>("saved_item_ids").apply { checkAuth(auth) }
suspend fun markItem(status: FeverDTO.StatusEnum, id: String): FeverDTO.Common =
postRequest<FeverDTO.Common>("&mark=item&as=${status.value}&id=$id").apply { checkAuth(auth) }
private suspend fun markFeedOrGroup(
act: String,
status: FeverDTO.StatusEnum,
id: Long,
before: Long,
): FeverDTO.Common = postRequest<FeverDTO.Common>("&mark=$act&as=${status.value}&id=$id&before=$before")
.apply { checkAuth(auth) }
suspend fun markGroup(status: FeverDTO.StatusEnum, id: Long, before: Long) =
markFeedOrGroup("group", status, id, before)
suspend fun markFeed(status: FeverDTO.StatusEnum, id: Long, before: Long) =
markFeedOrGroup("feed", status, id, before)
companion object {
private val instances: ConcurrentHashMap<String, FeverAPI> = ConcurrentHashMap()
fun getInstance(
serverUrl: String,
username: String,
password: String,
httpUsername: String? = null,
httpPassword: String? = null,
): FeverAPI = "$username:$password".md5().run {
instances.getOrPut("$serverUrl$this$httpUsername$httpPassword") {
FeverAPI(serverUrl, this, httpUsername, httpPassword)
}
}
}
}

View File

@ -1,6 +1,12 @@
package me.ash.reader.data.source
package me.ash.reader.data.provider.fever
object FeverApiDto {
object FeverDTO {
data class Common(
val api_version: Int?,
val auth: Int?,
val last_refreshed_on_time: Long?,
)
/**
* @link fever.php/?api=&feeds=
@ -28,22 +34,22 @@ object FeverApiDto {
* ]
* }
*/
data class Feed(
val api_version: Int,
val auth: Int,
val last_refreshed_on_time: Long,
val feeds: List<FeedItem>,
val feeds_groups: List<FeedsGroupsItem>,
data class Feeds(
val api_version: Int?,
val auth: Int?,
val last_refreshed_on_time: Long?,
val feeds: List<FeedItem>?,
val feeds_groups: List<FeedsGroupsItem>?,
)
data class FeedItem(
val id: Int,
val favicon_id: Int,
val title: String,
val url: String,
val site_url: String,
val is_spark: Int,
val last_refreshed_on_time: Long,
val id: Int?,
val favicon_id: Int?,
val title: String?,
val url: String?,
val site_url: String?,
val is_spark: Int?,
val last_refreshed_on_time: Long?,
)
/**
@ -68,21 +74,39 @@ object FeverApiDto {
* }
*/
data class Groups(
val api_version: Int,
val auth: Int,
val last_refreshed_on_time: Long,
val groups: List<GroupItem>,
val feeds_groups: List<FeedsGroupsItem>,
val api_version: Int?,
val auth: Int?,
val last_refreshed_on_time: Long?,
val groups: List<GroupItem>?,
val feeds_groups: List<FeedsGroupsItem>?,
)
data class GroupItem(
val id: Int,
val title: String,
val id: Int?,
val title: String?,
)
data class FeedsGroupsItem(
val group_id: Int,
val feed_ids: String,
val group_id: Int?,
val feed_ids: String?,
)
/**
* @link fever.php/?api=&favicons=
* @sample
* {
* }
*/
data class Favicons(
val api_version: Int?,
val auth: Int?,
val last_refreshed_on_time: Long?,
val favicons: Map<String, Favicon>,
)
data class Favicon(
val mime_type: String,
val data: String,
)
/**
@ -110,23 +134,49 @@ object FeverApiDto {
* {
*/
data class Items(
val api_version: Int,
val auth: Int,
val last_refreshed_on_time: Long,
val total_items: Int,
val items: List<Item>,
val api_version: Int?,
val auth: Int?,
val last_refreshed_on_time: Long?,
val total_items: Int?,
val items: List<Item>?,
)
data class Item(
val id: String,
val feed_id: Int,
val title: String,
val author: String,
val html: String,
val url: String,
val is_saved: Int,
val is_read: Int,
val created_on_time: Long,
val id: String?,
val feed_id: Int?,
val title: String?,
val author: String?,
val html: String?,
val url: String?,
val is_saved: Int?,
val is_read: Int?,
val created_on_time: Long?,
)
/**
* @link fever.php/?api=&links=
* @sample
* {
* }
*/
data class Links(
val api_version: Int?,
val auth: Int?,
val last_refreshed_on_time: Long?,
val links: List<Link>?,
)
data class Link(
val id: String?,
val feed_id: String?,
val item_id: String?,
val temperature: Float?,
val is_item: Boolean?,
val is_local: Boolean?,
val is_saved: Boolean?,
val title: String?,
val url: String?,
val item_ids: List<String>?,
)
/**
@ -140,10 +190,10 @@ object FeverApiDto {
* }
*/
data class ItemsByUnread(
val api_version: Int,
val auth: Int,
val last_refreshed_on_time: Long,
val unread_item_ids: String,
val api_version: Int?,
val auth: Int?,
val last_refreshed_on_time: Long?,
val unread_item_ids: String?,
)
/**
@ -157,9 +207,16 @@ object FeverApiDto {
* }
*/
data class ItemsByStarred(
val api_version: Int,
val auth: Int,
val last_refreshed_on_time: Long,
val saved_item_ids: String,
val api_version: Int?,
val auth: Int?,
val last_refreshed_on_time: Long?,
val saved_item_ids: String?,
)
}
enum class StatusEnum(val value: String) {
Read("read"),
Unread("unread"),
Saved("saved"),
Unsaved("unsaved"),
}
}

View File

@ -7,9 +7,12 @@ import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.supervisorScope
import me.ash.reader.data.dao.AccountDao
import me.ash.reader.data.dao.ArticleDao
import me.ash.reader.data.dao.FeedDao
@ -17,11 +20,13 @@ import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.model.article.Article
import me.ash.reader.data.model.article.ArticleWithFeed
import me.ash.reader.data.model.feed.Feed
import me.ash.reader.data.model.feed.FeedWithArticle
import me.ash.reader.data.model.group.Group
import me.ash.reader.data.model.group.GroupWithFeed
import me.ash.reader.data.model.preference.KeepArchivedPreference
import me.ash.reader.data.model.preference.SyncIntervalPreference
import me.ash.reader.ui.ext.currentAccountId
import me.ash.reader.ui.ext.spacerDollar
import java.util.*
abstract class AbstractRssRepository constructor(
@ -31,31 +36,135 @@ abstract class AbstractRssRepository constructor(
private val groupDao: GroupDao,
private val feedDao: FeedDao,
private val workManager: WorkManager,
private val rssHelper: RssHelper,
private val notificationHelper: NotificationHelper,
private val dispatcherIO: CoroutineDispatcher,
private val dispatcherDefault: CoroutineDispatcher,
) {
abstract suspend fun updateArticleInfo(article: Article)
open val subscribe = true
open val move = true
abstract suspend fun subscribe(feed: Feed, articles: List<Article>)
open suspend fun validCredentials(): Boolean = true
abstract suspend fun addGroup(name: String): String
open suspend fun subscribe(feed: Feed, articles: List<Article>) {
feedDao.insert(feed)
articleDao.insertList(articles.map {
it.copy(feedId = feed.id)
})
}
abstract suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result
open suspend fun addGroup(name: String): String {
context.currentAccountId.let { accountId ->
return accountId.spacerDollar(UUID.randomUUID().toString()).also {
groupDao.insert(
Group(
id = it,
name = name,
accountId = accountId
)
)
}
}
}
abstract suspend fun markAsRead(
open suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result =
supervisorScope {
coroutineWorker.setProgress(SyncWorker.setIsSyncing(true))
val preTime = System.currentTimeMillis()
val accountId = context.currentAccountId
feedDao.queryAll(accountId)
.chunked(16)
.forEach {
it.map { feed -> async { syncFeed(feed) } }
.awaitAll()
.forEach {
if (it.feed.isNotification) {
notificationHelper.notify(it.apply {
articles = articleDao.insertListIfNotExist(it.articles)
})
} else {
articleDao.insertListIfNotExist(it.articles)
}
}
}
Log.i("RlOG", "onCompletion: ${System.currentTimeMillis() - preTime}")
accountDao.queryById(accountId)?.let { account ->
accountDao.update(account.apply { updateAt = Date() })
}
coroutineWorker.setProgress(SyncWorker.setIsSyncing(false))
ListenableWorker.Result.success()
}
open suspend fun markAsRead(
groupId: String?,
feedId: String?,
articleId: String?,
before: Date?,
isUnread: Boolean,
)
) {
val accountId = context.currentAccountId
when {
groupId != null -> {
articleDao.markAllAsReadByGroupId(
accountId = accountId,
groupId = groupId,
isUnread = isUnread,
before = before ?: Date(Long.MAX_VALUE)
)
}
suspend fun keepArchivedArticles() {
feedId != null -> {
articleDao.markAllAsReadByFeedId(
accountId = accountId,
feedId = feedId,
isUnread = isUnread,
before = before ?: Date(Long.MAX_VALUE)
)
}
articleId != null -> {
articleDao.markAsReadByArticleId(accountId, articleId, isUnread)
}
else -> {
articleDao.markAllAsRead(accountId, isUnread, before ?: Date(Long.MAX_VALUE))
}
}
}
open suspend fun markAsStarred(articleId: String, isStarred: Boolean) {
val accountId = context.currentAccountId
articleDao.markAsStarredByArticleId(accountId, articleId, isStarred)
}
private suspend fun syncFeed(feed: Feed): FeedWithArticle {
val latest = articleDao.queryLatestByFeedId(context.currentAccountId, feed.id)
val articles = rssHelper.queryRssXml(feed, latest?.link)
// try {
// if (feed.icon == null && !articles.isNullOrEmpty()) {
// rssHelper.queryRssIcon(feedDao, feed, articles.first().link)
// }
// } catch (e: Exception) {
// Log.e("RLog", "queryRssIcon[${feed.name}]: ${e.message}")
// return FeedWithArticle(
// feed = feed.apply { isNotification = false },
// articles = listOf()
// )
// }
return FeedWithArticle(
feed = feed.apply { isNotification = feed.isNotification && articles.isNotEmpty() },
articles = articles
)
}
suspend fun clearKeepArchivedArticles() {
accountDao.queryById(context.currentAccountId)!!
.takeIf { it.keepArchived != KeepArchivedPreference.Always }
?.let {
articleDao.deleteAllArchivedBeforeThan(it.id!!, Date(System.currentTimeMillis() - it.keepArchived.value))
articleDao.deleteAllArchivedBeforeThan(it.id!!,
Date(System.currentTimeMillis() - it.keepArchived.value))
}
}

View File

@ -56,7 +56,7 @@ class AccountRepository @Inject constructor(
suspend fun addDefaultAccount(): Account =
addAccount(Account(
type = AccountType.Local,
name = context.getString(R.string.read_you)
name = context.getString(R.string.read_you),
))
suspend fun update(accountId: Int, block: Account.() -> Unit) {

View File

@ -0,0 +1,231 @@
package me.ash.reader.data.repository
import android.content.Context
import android.text.Html
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.withContext
import me.ash.reader.R
import me.ash.reader.data.dao.AccountDao
import me.ash.reader.data.dao.ArticleDao
import me.ash.reader.data.dao.FeedDao
import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.model.account.security.FeverSecurityKey
import me.ash.reader.data.model.article.Article
import me.ash.reader.data.model.feed.Feed
import me.ash.reader.data.model.group.Group
import me.ash.reader.data.module.DefaultDispatcher
import me.ash.reader.data.module.IODispatcher
import me.ash.reader.data.module.MainDispatcher
import me.ash.reader.data.provider.fever.FeverAPI
import me.ash.reader.data.provider.fever.FeverDTO
import me.ash.reader.ui.ext.currentAccountId
import me.ash.reader.ui.ext.dollarLast
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.ext.spacerDollar
import net.dankito.readability4j.extended.Readability4JExtended
import java.util.*
import javax.inject.Inject
class FeverRssRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val articleDao: ArticleDao,
private val feedDao: FeedDao,
private val rssHelper: RssHelper,
private val notificationHelper: NotificationHelper,
private val accountDao: AccountDao,
private val groupDao: GroupDao,
@IODispatcher
private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher
private val mainDispatcher: CoroutineDispatcher,
@DefaultDispatcher
private val defaultDispatcher: CoroutineDispatcher,
workManager: WorkManager,
) : AbstractRssRepository(
context, accountDao, articleDao, groupDao,
feedDao, workManager, rssHelper, notificationHelper, ioDispatcher, defaultDispatcher
) {
override val subscribe = false
override val move: Boolean = false
private suspend fun getFeverAPI() =
FeverSecurityKey(accountDao.queryById(context.currentAccountId)!!.securityKey).run {
FeverAPI.getInstance(
serverUrl = serverUrl!!,
username = username!!,
password = password!!,
httpUsername = null,
httpPassword = null,
)
}
override suspend fun validCredentials(): Boolean = getFeverAPI().validCredentials() > 0
override suspend fun subscribe(feed: Feed, articles: List<Article>) {
throw Exception("Unsupported")
}
override suspend fun addGroup(name: String): String {
throw Exception("Unsupported")
}
/**
* Sync handling for the Fever API.
*
* 1. Fetch the Fever groups
* 2. Fetch the Fever feeds
* 3. Fetch the Fever articles
* 4. Fetch the Fever favicons
*/
override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result =
supervisorScope {
coroutineWorker.setProgress(SyncWorker.setIsSyncing(true))
try {
val preTime = System.currentTimeMillis()
val accountId = context.currentAccountId
val feverAPI = getFeverAPI()
// 1. Fetch the Fever groups
groupDao.insert(
*feverAPI.getGroups().groups?.map {
Group(
id = accountId.spacerDollar(it.id!!),
name = it.title ?: context.getString(R.string.empty),
accountId = accountId,
)
}?.toTypedArray() ?: emptyArray()
)
// 2. Fetch the Fever feeds
val feedsBody = feverAPI.getFeeds()
val feedsGroupsMap = mutableMapOf<String, String>()
feedsBody.feeds_groups?.forEach { feedsGroups ->
feedsGroups.group_id?.toString()?.let { groupId ->
feedsGroups.feed_ids?.split(",")?.forEach { feedId ->
feedsGroupsMap[feedId] = groupId
}
}
}
feedDao.insert(
*feedsBody.feeds?.map {
Feed(
id = accountId.spacerDollar(it.id!!),
name = it.title ?: context.getString(R.string.empty),
url = it.url!!,
groupId = accountId.spacerDollar(feedsGroupsMap[it.id.toString()]!!),
accountId = accountId,
)
}?.toTypedArray() ?: emptyArray()
)
// 3. Fetch the Fever articles (up to unlimited counts)
var sinceId = ""
var itemsBody = feverAPI.getItemsSince(sinceId)
while (itemsBody.items?.isEmpty() == false) {
articleDao.insert(
*itemsBody.items?.map {
Article(
id = accountId.spacerDollar(it.id!!),
date = it.created_on_time?.run { Date(this * 1000) } ?: Date(),
title = Html.fromHtml(it.title ?: context.getString(R.string.empty)).toString(),
author = it.author,
rawDescription = it.html ?: "",
shortDescription = (Readability4JExtended("", it.html ?: "")
.parse().textContent ?: "")
.take(110)
.trim(),
fullContent = it.html,
img = rssHelper.findImg(it.html ?: ""),
link = it.url ?: "",
feedId = accountId.spacerDollar(it.feed_id!!),
accountId = accountId,
isUnread = (it.is_read ?: 0) <= 0,
isStarred = (it.is_saved ?: 0) > 0,
updateAt = Date(),
).also {
sinceId = it.id.dollarLast()
}
}?.toTypedArray() ?: emptyArray()
)
itemsBody = feverAPI.getItemsSince(sinceId)
}
// TODO: 4. Fetch the Fever favicons
Log.i("RLog", "onCompletion: ${System.currentTimeMillis() - preTime}")
accountDao.queryById(accountId)?.let { account ->
accountDao.update(account.apply { updateAt = Date() })
}
ListenableWorker.Result.success(SyncWorker.setIsSyncing(false))
} catch (e: Exception) {
Log.e("RLog", "On sync exception: ${e.message}", e)
withContext(mainDispatcher) {
context.showToast(e.message)
}
ListenableWorker.Result.failure(SyncWorker.setIsSyncing(false))
}
}
override suspend fun markAsRead(
groupId: String?,
feedId: String?,
articleId: String?,
before: Date?,
isUnread: Boolean,
) {
super.markAsRead(groupId, feedId, articleId, before, isUnread)
val feverAPI = getFeverAPI()
when {
groupId != null -> {
feverAPI.markGroup(
status = if (isUnread) FeverDTO.StatusEnum.Unread else FeverDTO.StatusEnum.Read,
id = groupId.dollarLast().toLong(),
before = before?.time ?: Date(Long.MAX_VALUE).time
)
}
feedId != null -> {
feverAPI.markFeed(
status = if (isUnread) FeverDTO.StatusEnum.Unread else FeverDTO.StatusEnum.Read,
id = feedId.dollarLast().toLong(),
before = before?.time ?: Date(Long.MAX_VALUE).time
)
}
articleId != null -> {
feverAPI.markItem(
status = if (isUnread) FeverDTO.StatusEnum.Unread else FeverDTO.StatusEnum.Read,
id = articleId.dollarLast(),
)
}
else -> {
feedDao.queryAll(context.currentAccountId).forEach {
feverAPI.markFeed(
status = if (isUnread) FeverDTO.StatusEnum.Unread else FeverDTO.StatusEnum.Read,
id = it.id.dollarLast().toLong(),
before = before?.time ?: Date(Long.MAX_VALUE).time
)
}
}
}
}
override suspend fun markAsStarred(articleId: String, isStarred: Boolean) {
super.markAsStarred(articleId, isStarred)
val feverAPI = getFeverAPI()
feverAPI.markItem(
status = if (isStarred) FeverDTO.StatusEnum.Saved else FeverDTO.StatusEnum.Unsaved,
id = articleId.dollarLast()
)
}
}

View File

@ -1,29 +1,15 @@
package me.ash.reader.data.repository
import android.content.Context
import android.util.Log
import androidx.work.CoroutineWorker
import androidx.work.ListenableWorker
import androidx.work.WorkManager
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.supervisorScope
import me.ash.reader.data.dao.AccountDao
import me.ash.reader.data.dao.ArticleDao
import me.ash.reader.data.dao.FeedDao
import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.model.article.Article
import me.ash.reader.data.model.feed.Feed
import me.ash.reader.data.model.feed.FeedWithArticle
import me.ash.reader.data.model.group.Group
import me.ash.reader.data.module.DefaultDispatcher
import me.ash.reader.data.module.IODispatcher
import me.ash.reader.data.repository.SyncWorker.Companion.setIsSyncing
import me.ash.reader.ui.ext.currentAccountId
import me.ash.reader.ui.ext.spacerDollar
import java.util.*
import javax.inject.Inject
class LocalRssRepository @Inject constructor(
@ -42,117 +28,5 @@ class LocalRssRepository @Inject constructor(
workManager: WorkManager,
) : AbstractRssRepository(
context, accountDao, articleDao, groupDao,
feedDao, workManager, ioDispatcher, defaultDispatcher
) {
override suspend fun updateArticleInfo(article: Article) {
articleDao.update(article)
}
override suspend fun subscribe(feed: Feed, articles: List<Article>) {
feedDao.insert(feed)
articleDao.insertList(articles.map {
it.copy(feedId = feed.id)
})
}
override suspend fun addGroup(name: String): String {
context.currentAccountId.let { accountId ->
return accountId.spacerDollar(UUID.randomUUID().toString()).also {
groupDao.insert(
Group(
id = it,
name = name,
accountId = accountId
)
)
}
}
}
override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result =
supervisorScope {
coroutineWorker.setProgress(setIsSyncing(true))
val preTime = System.currentTimeMillis()
val accountId = context.currentAccountId
feedDao.queryAll(accountId)
.chunked(16)
.forEach {
it.map { feed -> async { syncFeed(feed) } }
.awaitAll()
.forEach {
if (it.feed.isNotification) {
notificationHelper.notify(it.apply {
articles = articleDao.insertListIfNotExist(it.articles)
})
} else {
articleDao.insertListIfNotExist(it.articles)
}
}
}
Log.i("RlOG", "onCompletion: ${System.currentTimeMillis() - preTime}")
accountDao.queryById(accountId)?.let { account ->
accountDao.update(account.apply { updateAt = Date() })
}
coroutineWorker.setProgress(setIsSyncing(false))
ListenableWorker.Result.success()
}
override suspend fun markAsRead(
groupId: String?,
feedId: String?,
articleId: String?,
before: Date?,
isUnread: Boolean,
) {
val accountId = context.currentAccountId
when {
groupId != null -> {
articleDao.markAllAsReadByGroupId(
accountId = accountId,
groupId = groupId,
isUnread = isUnread,
before = before ?: Date(Long.MAX_VALUE)
)
}
feedId != null -> {
articleDao.markAllAsReadByFeedId(
accountId = accountId,
feedId = feedId,
isUnread = isUnread,
before = before ?: Date(Long.MAX_VALUE)
)
}
articleId != null -> {
articleDao.markAsReadByArticleId(accountId, articleId, isUnread)
}
else -> {
articleDao.markAllAsRead(accountId, isUnread, before ?: Date(Long.MAX_VALUE))
}
}
}
private suspend fun syncFeed(feed: Feed): FeedWithArticle {
val latest = articleDao.queryLatestByFeedId(context.currentAccountId, feed.id)
val articles = rssHelper.queryRssXml(feed, latest?.link)
// try {
// if (feed.icon == null && !articles.isNullOrEmpty()) {
// rssHelper.queryRssIcon(feedDao, feed, articles.first().link)
// }
// } catch (e: Exception) {
// Log.e("RLog", "queryRssIcon[${feed.name}]: ${e.message}")
// return FeedWithArticle(
// feed = feed.apply { isNotification = false },
// articles = listOf()
// )
// }
return FeedWithArticle(
feed = feed.apply { isNotification = feed.isNotification && articles.isNotEmpty() },
articles = articles
)
}
}
feedDao, workManager, rssHelper, notificationHelper, ioDispatcher, defaultDispatcher
)

View File

@ -11,7 +11,7 @@ import me.ash.reader.data.dao.AccountDao
import me.ash.reader.data.dao.FeedDao
import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.model.feed.Feed
import me.ash.reader.data.source.OpmlLocalDataSource
import me.ash.reader.data.source.OPMLDataSource
import me.ash.reader.ui.ext.currentAccountId
import me.ash.reader.ui.ext.getDefaultGroupId
import java.io.InputStream
@ -28,7 +28,7 @@ class OpmlRepository @Inject constructor(
private val feedDao: FeedDao,
private val accountDao: AccountDao,
private val rssRepository: RssRepository,
private val opmlLocalDataSource: OpmlLocalDataSource,
private val OPMLDataSource: OPMLDataSource,
) {
/**
@ -40,7 +40,7 @@ class OpmlRepository @Inject constructor(
suspend fun saveToDatabase(inputStream: InputStream) {
val defaultGroup = groupDao.queryById(getDefaultGroupId(context.currentAccountId))!!
val groupWithFeedList =
opmlLocalDataSource.parseFileInputStream(inputStream, defaultGroup)
OPMLDataSource.parseFileInputStream(inputStream, defaultGroup)
groupWithFeedList.forEach { groupWithFeed ->
if (groupWithFeed.group != defaultGroup) {
groupDao.insert(groupWithFeed.group)

View File

@ -25,7 +25,7 @@ import javax.inject.Inject
class RYRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val RYNetworkDataSource: RYNetworkDataSource,
private val networkDataSource: RYNetworkDataSource,
@IODispatcher
private val ioDispatcher: CoroutineDispatcher,
@MainDispatcher
@ -34,7 +34,7 @@ class RYRepository @Inject constructor(
suspend fun checkUpdate(showToast: Boolean = true): Boolean? = withContext(ioDispatcher) {
try {
val response = RYNetworkDataSource.getReleaseLatest(context.getString(R.string.update_link))
val response = networkDataSource.getReleaseLatest(context.getString(R.string.update_link))
when {
response.code() == 403 -> {
withContext(mainDispatcher) {
@ -86,7 +86,7 @@ class RYRepository @Inject constructor(
withContext(ioDispatcher) {
Log.i("RLog", "downloadFile start: $url")
try {
return@withContext RYNetworkDataSource.downloadFile(url)
return@withContext networkDataSource.downloadFile(url)
.downloadToFileWithProgress(context.getLatestApk())
} catch (e: Exception) {
e.printStackTrace()

View File

@ -130,7 +130,7 @@ class RssHelper @Inject constructor(
)
}
private fun findImg(rawDescription: String): String? {
fun findImg(rawDescription: String): String? {
// From: https://gitlab.com/spacecowboy/Feeder
// Using negative lookahead to skip data: urls, being inline base64
// And capturing original quote to use as ending quote

View File

@ -10,7 +10,7 @@ class RssRepository @Inject constructor(
@ApplicationContext
private val context: Context,
private val localRssRepository: LocalRssRepository,
// private val feverRssRepository: FeverRssRepository,
private val feverRssRepository: FeverRssRepository,
// private val googleReaderRssRepository: GoogleReaderRssRepository,
) {
@ -18,9 +18,10 @@ class RssRepository @Inject constructor(
fun get(accountTypeId: Int) = when (accountTypeId) {
AccountType.Local.id -> localRssRepository
// Account.Type.LOCAL -> feverRssRepository
// Account.Type.FEVER -> feverRssRepository
// Account.Type.GOOGLE_READER -> googleReaderRssRepository
AccountType.Fever.id -> feverRssRepository
AccountType.GoogleReader.id -> localRssRepository
AccountType.Inoreader.id -> localRssRepository
AccountType.Feedly.id -> localRssRepository
else -> localRssRepository
}
}

View File

@ -26,7 +26,7 @@ class SyncWorker @AssistedInject constructor(
withContext(Dispatchers.Default) {
Log.i("RLog", "doWork: ")
rssRepository.get().sync(this@SyncWorker).also {
rssRepository.get().keepArchivedArticles()
rssRepository.get().clearKeepArchivedArticles()
}
}

View File

@ -1,60 +0,0 @@
package me.ash.reader.data.source
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Multipart
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Query
interface FeverApiDataSource {
@Multipart
@POST("fever.php/?api=&feeds=")
fun feeds(@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call<FeverApiDto.Feed>
@Multipart
@POST("fever.php/?api=&groups=")
fun groups(@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call<FeverApiDto.Groups>
@Multipart
@POST("fever.php/?api=&items=")
fun itemsBySince(
@Query("since_id") since: Long,
@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody(),
): Call<FeverApiDto.Items>
@Multipart
@POST("fever.php/?api=&unread_item_ids=")
fun itemsByUnread(@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call<FeverApiDto.ItemsByUnread>
@Multipart
@POST("fever.php/?api=&saved_item_ids=")
fun itemsByStarred(@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call<FeverApiDto.ItemsByStarred>
@Multipart
@POST("fever.php/?api=&items=")
fun itemsByIds(
@Query("with_ids") ids: String,
@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody(),
): Call<FeverApiDto.Items>
companion object {
private var instance: FeverApiDataSource? = null
fun getInstance(): FeverApiDataSource {
return instance ?: synchronized(this) {
instance ?: Retrofit.Builder()
.baseUrl("http://10.0.2.2/api/")
.addConverterFactory(GsonConverterFactory.create())
.build().create(FeverApiDataSource::class.java).also {
instance = it
}
}
}
}
}

View File

@ -1,45 +0,0 @@
package me.ash.reader.data.source
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.Headers
import retrofit2.http.POST
interface GoogleReaderApiDataSource {
@POST("accounts/ClientLogin")
fun login(Email: String, Passwd: String): Call<String>
@Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}")
@POST("reader/api/0/subscription/list?output=json")
fun subscriptionList(): Call<GoogleReaderApiDto.SubscriptionList>
@Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}")
@POST("reader/api/0/unread-count?output=json")
fun unreadCount(): Call<GoogleReaderApiDto.UnreadCount>
@Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}")
@POST("reader/api/0/tag/list?output=json")
fun tagList(): Call<GoogleReaderApiDto.TagList>
@Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}")
@POST("reader/api/0/stream/contents/reading-list")
fun readingList(): Call<GoogleReaderApiDto.ReadingList>
companion object {
private var instance: GoogleReaderApiDataSource? = null
fun getInstance(): GoogleReaderApiDataSource {
return instance ?: synchronized(this) {
instance ?: Retrofit.Builder()
.baseUrl("http://10.0.2.2/api/greader.php/")
.addConverterFactory(GsonConverterFactory.create())
.build().create(GoogleReaderApiDataSource::class.java).also {
instance = it
}
}
}
}
}

View File

@ -15,7 +15,7 @@ import java.io.InputStream
import java.util.*
import javax.inject.Inject
class OpmlLocalDataSource @Inject constructor(
class OPMLDataSource @Inject constructor(
@ApplicationContext
private val context: Context,
@IODispatcher

View File

@ -9,6 +9,7 @@ import me.ash.reader.data.dao.ArticleDao
import me.ash.reader.data.dao.FeedDao
import me.ash.reader.data.dao.GroupDao
import me.ash.reader.data.model.account.*
import me.ash.reader.data.model.account.security.DESUtils
import me.ash.reader.data.model.article.Article
import me.ash.reader.data.model.feed.Feed
import me.ash.reader.data.model.group.Group
@ -18,7 +19,7 @@ import java.util.*
@Database(
entities = [Account::class, Feed::class, Article::class, Group::class],
version = 3
version = 4
)
@TypeConverters(
RYDatabase.DateConverters::class,
@ -71,6 +72,7 @@ abstract class RYDatabase : RoomDatabase() {
val allMigrations = arrayOf(
MIGRATION_1_2,
MIGRATION_2_3,
MIGRATION_3_4,
)
@Suppress("ClassName")
@ -126,3 +128,15 @@ object MIGRATION_2_3 : Migration(2, 3) {
)
}
}
@Suppress("ClassName")
object MIGRATION_3_4 : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
ALTER TABLE account ADD COLUMN securityKey TEXT DEFAULT '${DESUtils.empty}'
""".trimIndent()
)
}
}

View File

@ -26,6 +26,7 @@ fun ClipboardTextField(
singleLine: Boolean = true,
onValueChange: (String) -> Unit = {},
placeholder: String = "",
isPassword: Boolean = false,
errorText: String = "",
imeAction: ImeAction = ImeAction.Done,
focusManager: FocusManager? = null,
@ -39,6 +40,7 @@ fun ClipboardTextField(
singleLine = singleLine,
onValueChange = onValueChange,
placeholder = placeholder,
isPassword = isPassword,
errorMessage = errorText,
keyboardActions = KeyboardActions(
onDone = if (imeAction == ImeAction.Done)

View File

@ -5,16 +5,18 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ContentPaste
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import kotlinx.coroutines.delay
import me.ash.reader.R
@ -25,6 +27,8 @@ fun RYOutlineTextField(
label: String = "",
singleLine: Boolean = true,
onValueChange: (String) -> Unit,
visualTransformation: VisualTransformation = VisualTransformation.None,
isPassword: Boolean = false,
placeholder: String = "",
errorMessage: String = "",
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
@ -32,6 +36,7 @@ fun RYOutlineTextField(
) {
val clipboardManager = LocalClipboardManager.current
val focusRequester = remember { FocusRequester() }
var showPassword by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(100) // ???
@ -52,6 +57,7 @@ fun RYOutlineTextField(
onValueChange = {
if (!readOnly) onValueChange(it)
},
visualTransformation = if (isPassword && !showPassword) PasswordVisualTransformation() else visualTransformation,
placeholder = {
Text(
text = placeholder,
@ -64,11 +70,18 @@ fun RYOutlineTextField(
trailingIcon = {
if (value.isNotEmpty()) {
IconButton(onClick = {
if (!readOnly) onValueChange("")
if (isPassword) {
showPassword = !showPassword
} else if (!readOnly) {
onValueChange("")
}
}) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(R.string.clear),
imageVector = if (isPassword) {
if (showPassword) Icons.Rounded.Visibility
else Icons.Rounded.VisibilityOff
} else Icons.Rounded.Close,
contentDescription = if (isPassword) stringResource(R.string.password) else stringResource(R.string.clear),
tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
)
}

View File

@ -5,16 +5,18 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material.icons.rounded.ContentPaste
import androidx.compose.material.icons.rounded.Visibility
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import kotlinx.coroutines.delay
import me.ash.reader.R
@ -25,6 +27,8 @@ fun RYTextField(
label: String = "",
singleLine: Boolean = true,
onValueChange: (String) -> Unit,
visualTransformation: VisualTransformation = VisualTransformation.None,
isPassword: Boolean = false,
placeholder: String = "",
errorMessage: String = "",
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
@ -32,6 +36,7 @@ fun RYTextField(
) {
val clipboardManager = LocalClipboardManager.current
val focusRequester = remember { FocusRequester() }
var showPassword by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
delay(100) // ???
@ -52,6 +57,7 @@ fun RYTextField(
onValueChange = {
if (!readOnly) onValueChange(it)
},
visualTransformation = if (isPassword && !showPassword) PasswordVisualTransformation() else visualTransformation,
placeholder = {
Text(
text = placeholder,
@ -64,11 +70,18 @@ fun RYTextField(
trailingIcon = {
if (value.isNotEmpty()) {
IconButton(onClick = {
if (!readOnly) onValueChange("")
if (isPassword) {
showPassword = !showPassword
} else if (!readOnly) {
onValueChange("")
}
}) {
Icon(
imageVector = Icons.Rounded.Close,
contentDescription = stringResource(R.string.clear),
imageVector = if (isPassword) {
if (showPassword) Icons.Rounded.Visibility
else Icons.Rounded.VisibilityOff
} else Icons.Rounded.Close,
contentDescription = if (isPassword) stringResource(R.string.password) else stringResource(R.string.clear),
tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f),
)
}

View File

@ -26,6 +26,7 @@ fun TextFieldDialog(
icon: ImageVector? = null,
value: String = "",
placeholder: String = "",
isPassword: Boolean = false,
errorText: String = "",
dismissText: String = stringResource(R.string.cancel),
confirmText: String = stringResource(R.string.confirm),
@ -59,6 +60,7 @@ fun TextFieldDialog(
singleLine = singleLine,
onValueChange = onValueChange,
placeholder = placeholder,
isPassword = isPassword,
errorText = errorText,
imeAction = imeAction,
focusManager = focusManager,

View File

@ -2,6 +2,8 @@ package me.ash.reader.ui.ext
fun Int.spacerDollar(str: Any): String = "$this$$str"
fun String.dollarLast(): String = split("$").last()
fun Int.getDefaultGroupId() = this.spacerDollar("read_you_app_default_group")
fun Int.toBoolean(): Boolean = this != 0

View File

@ -1,5 +1,9 @@
package me.ash.reader.ui.ext
import android.util.Base64
import java.math.BigInteger
import java.security.MessageDigest
fun String.formatUrl(): String {
if (this.startsWith("//")) {
return "https:$this"
@ -15,4 +19,16 @@ fun String.formatUrl(): String {
fun String.isUrl(): Boolean {
val regex = Regex("(https?|ftp|file)://[-A-Za-z0-9+&@#/%?=~_|!:,.;]+[-A-Za-z0-9+&@#/%=~_|]")
return regex.matches(this)
}
}
fun String.mask(): String = run {
"\u2022".repeat(length)
}
fun String.encodeBase64(): String = Base64.encodeToString(toByteArray(), Base64.DEFAULT)
fun String.decodeBase64(): String = String(Base64.decode(this, Base64.DEFAULT))
fun String.md5(): String =
BigInteger(1, MessageDigest.getInstance("MD5").digest(toByteArray()))
.toString(16).padStart(32, '0')

View File

@ -38,6 +38,7 @@ fun FeedOptionView(
selectedAllowNotificationPreset: Boolean = false,
selectedParseFullContentPreset: Boolean = false,
isMoveToGroup: Boolean = false,
showGroup:Boolean = true,
showUnsubscribe: Boolean = false,
selectedGroupId: String = "",
allowNotificationPresetOnClick: () -> Unit = {},
@ -72,15 +73,18 @@ fun FeedOptionView(
clearArticlesOnClick = clearArticlesOnClick,
unsubscribeOnClick = unsubscribeOnClick,
)
Spacer(modifier = Modifier.height(26.dp))
AddToGroup(
isMoveToGroup = isMoveToGroup,
groups = groups,
selectedGroupId = selectedGroupId,
onGroupClick = onGroupClick,
onAddNewGroup = onAddNewGroup,
)
if (showGroup) {
Spacer(modifier = Modifier.height(26.dp))
AddToGroup(
isMoveToGroup = isMoveToGroup,
groups = groups,
selectedGroupId = selectedGroupId,
onGroupClick = onGroupClick,
onAddNewGroup = onAddNewGroup,
)
}
Spacer(modifier = Modifier.height(6.dp))
}
}

View File

@ -22,9 +22,9 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.work.WorkInfo
import me.ash.reader.R
import me.ash.reader.data.model.preference.*
import me.ash.reader.data.repository.SyncWorker.Companion.getIsSyncing
import me.ash.reader.ui.component.FilterBar
import me.ash.reader.ui.component.base.*
import me.ash.reader.ui.ext.*
@ -79,7 +79,7 @@ fun FeedsPage(
val owner = LocalLifecycleOwner.current
var isSyncing by remember { mutableStateOf(false) }
homeViewModel.syncWorkLiveData.observe(owner) {
it?.let { isSyncing = it.any { it.progress.getIsSyncing() } }
it?.let { isSyncing = it.any { it.state == WorkInfo.State.RUNNING } }
}
val infiniteTransition = rememberInfiniteTransition()
@ -148,12 +148,14 @@ fun FeedsPage(
) {
if (!isSyncing) homeViewModel.sync()
}
FeedbackIconButton(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(R.string.subscribe),
tint = MaterialTheme.colorScheme.onSurface,
) {
subscribeViewModel.showDrawer()
if (subscribeViewModel.rssRepository.get().subscribe) {
FeedbackIconButton(
imageVector = Icons.Rounded.Add,
contentDescription = stringResource(R.string.subscribe),
tint = MaterialTheme.colorScheme.onSurface,
) {
subscribeViewModel.showDrawer()
}
}
},
content = {

View File

@ -86,6 +86,7 @@ fun FeedOptionDrawer(
?: false,
selectedParseFullContentPreset = feedOptionUiState.feed?.isFullContent ?: false,
isMoveToGroup = true,
showGroup = feedOptionViewModel.rssRepository.get().move,
showUnsubscribe = true,
selectedGroupId = feedOptionUiState.feed?.groupId ?: "",
allowNotificationPresetOnClick = {

View File

@ -24,7 +24,7 @@ import javax.inject.Inject
@OptIn(ExperimentalMaterialApi::class)
@HiltViewModel
class FeedOptionViewModel @Inject constructor(
private val rssRepository: RssRepository,
val rssRepository: RssRepository,
@MainDispatcher
private val mainDispatcher: CoroutineDispatcher,
@IODispatcher

View File

@ -22,7 +22,7 @@ import javax.inject.Inject
@HiltViewModel
class SubscribeViewModel @Inject constructor(
private val opmlRepository: OpmlRepository,
private val rssRepository: RssRepository,
val rssRepository: RssRepository,
private val rssHelper: RssHelper,
private val stringsRepository: StringsRepository,
) : ViewModel() {

View File

@ -20,6 +20,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.work.WorkInfo
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import me.ash.reader.R
@ -69,7 +70,7 @@ fun FlowPage(
val owner = LocalLifecycleOwner.current
var isSyncing by remember { mutableStateOf(false) }
homeViewModel.syncWorkLiveData.observe(owner) {
it?.let { isSyncing = it.any { it.progress.getIsSyncing() } }
it?.let { isSyncing = it.any { it.state == WorkInfo.State.RUNNING } }
}
LaunchedEffect(onSearch) {

View File

@ -116,8 +116,9 @@ class ReadingViewModel @Inject constructor(
)
)
}
rssRepository.get().updateArticleInfo(
articleWithFeed.article.copy(isStarred = isStarred)
rssRepository.get().markAsStarred(
articleId = articleWithFeed.article.id,
isStarred = isStarred,
)
}
}

View File

@ -28,6 +28,7 @@ import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.ext.showToastLong
import me.ash.reader.ui.page.settings.SettingItem
import me.ash.reader.ui.page.settings.accounts.connection.AccountConnection
import me.ash.reader.ui.theme.palette.onLight
@OptIn(ExperimentalAnimationApi::class)
@ -36,21 +37,17 @@ fun AccountDetailsPage(
navController: NavHostController = rememberAnimatedNavController(),
viewModel: AccountViewModel = hiltViewModel(),
) {
val scope = rememberCoroutineScope()
val uiState = viewModel.accountUiState.collectAsStateValue()
val context = LocalContext.current
val syncInterval = LocalSyncInterval.current
val syncOnStart = LocalSyncOnStart.current
val syncOnlyOnWiFi = LocalSyncOnlyOnWiFi.current
val syncOnlyWhenCharging = LocalSyncOnlyWhenCharging.current
val keepArchived = LocalKeepArchived.current
val syncBlockList = LocalSyncBlockList.current
val selectedAccount = uiState.selectedAccount.collectAsStateValue(initial = null)
var nameValue by remember { mutableStateOf(selectedAccount?.name) }
var nameDialogVisible by remember { mutableStateOf(false) }
var blockListValue by remember { mutableStateOf(SyncBlockListPreference.toString(syncBlockList)) }
var blockListValue by remember {
mutableStateOf(SyncBlockListPreference.toString(selectedAccount?.syncBlockList
?: SyncBlockListPreference.default))
}
var blockListDialogVisible by remember { mutableStateOf(false) }
var syncIntervalDialogVisible by remember { mutableStateOf(false) }
var keepArchivedDialogVisible by remember { mutableStateOf(false) }
@ -107,6 +104,11 @@ fun AccountDetailsPage(
) {}
Spacer(modifier = Modifier.height(24.dp))
}
if (selectedAccount != null) {
item {
AccountConnection(account = selectedAccount)
}
}
item {
Subtitle(
modifier = Modifier.padding(horizontal = 24.dp),
@ -114,20 +116,20 @@ fun AccountDetailsPage(
)
SettingItem(
title = stringResource(R.string.sync_interval),
desc = syncInterval.toDesc(context),
desc = selectedAccount?.syncInterval?.toDesc(context),
onClick = { syncIntervalDialogVisible = true },
) {}
SettingItem(
title = stringResource(R.string.sync_once_on_start),
onClick = {
selectedAccount?.id?.let {
(!syncOnStart).put(it, viewModel)
(!selectedAccount.syncOnStart).put(it, viewModel)
}
},
) {
RYSwitch(activated = syncOnStart.value) {
RYSwitch(activated = selectedAccount?.syncOnStart?.value == true) {
selectedAccount?.id?.let {
(!syncOnStart).put(it, viewModel)
(!selectedAccount.syncOnStart).put(it, viewModel)
}
}
}
@ -135,13 +137,13 @@ fun AccountDetailsPage(
title = stringResource(R.string.only_on_wifi),
onClick = {
selectedAccount?.id?.let {
(!syncOnlyOnWiFi).put(it, viewModel)
(!selectedAccount.syncOnlyOnWiFi).put(it, viewModel)
}
},
) {
RYSwitch(activated = syncOnlyOnWiFi.value) {
RYSwitch(activated = selectedAccount?.syncOnlyOnWiFi?.value == true) {
selectedAccount?.id?.let {
(!syncOnlyOnWiFi).put(it, viewModel)
(!selectedAccount.syncOnlyOnWiFi).put(it, viewModel)
}
}
}
@ -149,19 +151,19 @@ fun AccountDetailsPage(
title = stringResource(R.string.only_when_charging),
onClick = {
selectedAccount?.id?.let {
(!syncOnlyWhenCharging).put(it, viewModel)
(!selectedAccount.syncOnlyWhenCharging).put(it, viewModel)
}
},
) {
RYSwitch(activated = syncOnlyWhenCharging.value) {
RYSwitch(activated = selectedAccount?.syncOnlyWhenCharging?.value == true) {
selectedAccount?.id?.let {
(!syncOnlyWhenCharging).put(it, viewModel)
(!selectedAccount.syncOnlyWhenCharging).put(it, viewModel)
}
}
}
SettingItem(
title = stringResource(R.string.keep_archived_articles),
desc = keepArchived.toDesc(context),
desc = selectedAccount?.keepArchived?.toDesc(context),
onClick = { keepArchivedDialogVisible = true },
) {}
// SettingItem(
@ -229,7 +231,7 @@ fun AccountDetailsPage(
options = SyncIntervalPreference.values.map {
RadioDialogOption(
text = it.toDesc(context),
selected = it == syncInterval,
selected = it == selectedAccount?.syncInterval,
) {
selectedAccount?.id?.let { accountId ->
it.put(accountId, viewModel)
@ -246,7 +248,7 @@ fun AccountDetailsPage(
options = KeepArchivedPreference.values.map {
RadioDialogOption(
text = it.toDesc(context),
selected = it == keepArchived,
selected = it == selectedAccount?.keepArchived,
) {
selectedAccount?.id?.let { accountId ->
it.put(accountId, viewModel)
@ -271,7 +273,7 @@ fun AccountDetailsPage(
},
onConfirm = {
selectedAccount?.id?.let {
SyncBlockListPreference.put(it, viewModel, syncBlockList)
SyncBlockListPreference.put(it, viewModel, selectedAccount.syncBlockList)
blockListDialogVisible = false
context.showToast(selectedAccount.syncBlockList.toString())
}

View File

@ -90,11 +90,22 @@ class AccountViewModel @Inject constructor(
}
}
fun addAccount(account: Account, callback: (Account) -> Unit = {}) {
fun addAccount(account: Account, callback: (Account?) -> Unit = {}) {
viewModelScope.launch(ioDispatcher) {
val addAccount = accountRepository.addAccount(account)
withContext(mainDispatcher) {
callback(addAccount)
try {
if (rssRepository.get(addAccount.type.id).validCredentials()) {
withContext(mainDispatcher) {
callback(addAccount)
}
} else {
throw Exception("Unauthorized")
}
} catch (e: Exception) {
accountRepository.delete(account.id!!)
withContext(mainDispatcher) {
callback(null)
}
}
}
}

View File

@ -23,6 +23,7 @@ import me.ash.reader.ui.component.base.FeedbackIconButton
import me.ash.reader.ui.component.base.RYScaffold
import me.ash.reader.ui.component.base.Subtitle
import me.ash.reader.ui.page.settings.SettingItem
import me.ash.reader.ui.page.settings.accounts.addition.AddFeverAccountDialog
import me.ash.reader.ui.page.settings.accounts.addition.AddLocalAccountDialog
import me.ash.reader.ui.page.settings.accounts.addition.AdditionViewModel
import me.ash.reader.ui.theme.palette.onLight
@ -113,20 +114,11 @@ fun AddAccountsPage(
},
) {}
SettingItem(
enable = false,
title = stringResource(R.string.fever),
desc = stringResource(R.string.fever_desc),
iconPainter = painterResource(id = R.drawable.ic_fever),
onClick = {
// viewModel.addAccount(Account(
// type = AccountType.Fever,
// name = "name",
// )) {
// navController.popBackStack()
// navController.navigate("${RouteName.ACCOUNT_DETAILS}/${it.id}") {
// launchSingleTop = true
// }
// }
additionViewModel.showAddFeverAccountDialog()
},
) {}
Spacer(modifier = Modifier.height(24.dp))
@ -140,6 +132,7 @@ fun AddAccountsPage(
)
AddLocalAccountDialog(navController)
AddFeverAccountDialog(navController)
}
@Preview

View File

@ -0,0 +1,145 @@
package me.ash.reader.ui.page.settings.accounts.addition
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.RssFeed
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.DialogProperties
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavHostController
import me.ash.reader.R
import me.ash.reader.data.model.account.Account
import me.ash.reader.data.model.account.AccountType
import me.ash.reader.data.model.account.security.FeverSecurityKey
import me.ash.reader.ui.component.base.RYDialog
import me.ash.reader.ui.component.base.RYOutlineTextField
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.page.common.RouteName
import me.ash.reader.ui.page.settings.accounts.AccountViewModel
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
@Composable
fun AddFeverAccountDialog(
navController: NavHostController,
viewModel: AdditionViewModel = hiltViewModel(),
accountViewModel: AccountViewModel = hiltViewModel(),
) {
val context = LocalContext.current
val focusManager = LocalFocusManager.current
val uiState = viewModel.additionUiState.collectAsStateValue()
var serverUrl by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
RYDialog(
modifier = Modifier.padding(horizontal = 44.dp),
visible = uiState.addFeverAccountDialogVisible,
properties = DialogProperties(usePlatformDefaultWidth = false),
onDismissRequest = {
focusManager.clearFocus()
// subscribeViewModel.hideDrawer()
},
icon = {
Icon(
imageVector = Icons.Rounded.RssFeed,
contentDescription = stringResource(R.string.fever),
)
},
title = {
Text(
text = stringResource(R.string.fever),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = stringResource(R.string.server_url),
placeholder = "https://demo.freshrss.org/api/fever.php",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
)
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
value = username,
onValueChange = { username = it },
label = stringResource(R.string.username),
placeholder = "demo",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
)
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
value = password,
onValueChange = { password = it },
isPassword = true,
label = stringResource(R.string.password),
placeholder = "demodemo",
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
)
Spacer(modifier = Modifier.height(10.dp))
}
},
confirmButton = {
TextButton(
enabled = serverUrl.isNotBlank() && username.isNotEmpty() && password.isNotEmpty(),
onClick = {
focusManager.clearFocus()
accountViewModel.addAccount(Account(
type = AccountType.Fever,
name = context.getString(R.string.fever),
securityKey = FeverSecurityKey(
serverUrl = serverUrl,
username = username,
password = password,
).toString(),
)) {
if (it == null) {
context.showToast("Not valid credentials")
} else {
viewModel.hideAddFeverAccountDialog()
navController.popBackStack()
navController.navigate("${RouteName.ACCOUNT_DETAILS}/${it.id}") {
launchSingleTop = true
}
}
}
}
) {
Text(stringResource(R.string.add))
}
},
dismissButton = {
TextButton(
onClick = {
focusManager.clearFocus()
viewModel.hideAddFeverAccountDialog()
}
) {
Text(stringResource(R.string.cancel))
}
},
)
}

View File

@ -1,6 +1,8 @@
package me.ash.reader.ui.page.settings.accounts.addition
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
@ -27,6 +29,7 @@ import me.ash.reader.data.model.account.AccountType
import me.ash.reader.ui.component.base.RYDialog
import me.ash.reader.ui.component.base.RYOutlineTextField
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.page.common.RouteName
import me.ash.reader.ui.page.settings.accounts.AccountViewModel
@ -68,12 +71,14 @@ fun AddLocalAccountDialog(
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Spacer(modifier = Modifier.height(10.dp))
RYOutlineTextField(
value = name,
onValueChange = { name = it },
label = stringResource(R.string.name),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email)
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
)
Spacer(modifier = Modifier.height(10.dp))
}
},
confirmButton = {
@ -81,15 +86,18 @@ fun AddLocalAccountDialog(
enabled = name.isNotBlank(),
onClick = {
focusManager.clearFocus()
viewModel.hideAddLocalAccountDialog()
accountViewModel.addAccount(Account(
type = AccountType.Local,
name = name,
)) {
navController.popBackStack()
navController.navigate("${RouteName.ACCOUNT_DETAILS}/${it.id}") {
launchSingleTop = true
if (it == null) {
context.showToast("Not valid credentials")
} else {
viewModel.hideAddLocalAccountDialog()
navController.popBackStack()
navController.navigate("${RouteName.ACCOUNT_DETAILS}/${it.id}") {
launchSingleTop = true
}
}
}
}

View File

@ -0,0 +1,36 @@
package me.ash.reader.ui.page.settings.accounts.connection
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import me.ash.reader.R
import me.ash.reader.data.model.account.Account
import me.ash.reader.data.model.account.AccountType
import me.ash.reader.ui.component.base.Subtitle
@Composable
fun LazyItemScope.AccountConnection(
account: Account,
) {
if (account.type.id != AccountType.Local.id) {
Subtitle(
modifier = Modifier.padding(horizontal = 24.dp),
text = stringResource(R.string.connection),
)
}
when (account.type.id) {
AccountType.Fever.id -> FeverConnection(account)
AccountType.GoogleReader.id -> {}
AccountType.FreshRSS.id -> {}
AccountType.Feedly.id -> {}
AccountType.Inoreader.id -> {}
}
if (account.type.id != AccountType.Local.id) {
Spacer(modifier = Modifier.height(24.dp))
}
}

View File

@ -0,0 +1,132 @@
package me.ash.reader.ui.page.settings.accounts.connection
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.runtime.*
import androidx.compose.ui.res.stringResource
import androidx.hilt.navigation.compose.hiltViewModel
import me.ash.reader.R
import me.ash.reader.data.model.account.Account
import me.ash.reader.data.model.account.security.FeverSecurityKey
import me.ash.reader.ui.component.base.TextFieldDialog
import me.ash.reader.ui.ext.mask
import me.ash.reader.ui.page.settings.SettingItem
import me.ash.reader.ui.page.settings.accounts.AccountViewModel
@Composable
fun LazyItemScope.FeverConnection(
account: Account,
viewModel: AccountViewModel = hiltViewModel(),
) {
val securityKey by remember {
derivedStateOf { FeverSecurityKey(account.securityKey) }
}
var passwordMask by remember { mutableStateOf(securityKey.password?.mask()) }
var serverUrlValue by remember { mutableStateOf(securityKey.serverUrl) }
var usernameValue by remember { mutableStateOf(securityKey.username) }
var passwordValue by remember { mutableStateOf(securityKey.password) }
var serverUrlDialogVisible by remember { mutableStateOf(false) }
var usernameDialogVisible by remember { mutableStateOf(false) }
var passwordDialogVisible by remember { mutableStateOf(false) }
LaunchedEffect(securityKey.password) {
passwordMask = securityKey.password?.mask()
}
SettingItem(
title = stringResource(R.string.server_url),
desc = securityKey.serverUrl ?: "",
onClick = {
serverUrlDialogVisible = true
},
) {}
SettingItem(
title = stringResource(R.string.username),
desc = securityKey.username ?: "",
onClick = {
usernameDialogVisible = true
},
) {}
SettingItem(
title = stringResource(R.string.password),
desc = passwordMask,
onClick = {
passwordDialogVisible = true
},
) {}
TextFieldDialog(
visible = serverUrlDialogVisible,
title = stringResource(R.string.server_url),
value = serverUrlValue ?: "",
placeholder = "https://demo.freshrss.org/api/fever.php",
onValueChange = {
serverUrlValue = it
},
onDismissRequest = {
serverUrlDialogVisible = false
},
onConfirm = {
if (securityKey.serverUrl?.isNotBlank() == true) {
securityKey.serverUrl = serverUrlValue
save(account, viewModel, securityKey)
serverUrlDialogVisible = false
}
}
)
TextFieldDialog(
visible = usernameDialogVisible,
title = stringResource(R.string.username),
value = usernameValue ?: "",
placeholder = "demo",
onValueChange = {
usernameValue = it
},
onDismissRequest = {
usernameDialogVisible = false
},
onConfirm = {
if (securityKey.username?.isNotEmpty() == true) {
securityKey.username = usernameValue
save(account, viewModel, securityKey)
usernameDialogVisible = false
}
}
)
TextFieldDialog(
visible = passwordDialogVisible,
title = stringResource(R.string.password),
value = passwordValue ?: "",
placeholder = "demodemo",
isPassword = true,
onValueChange = {
passwordValue = it
},
onDismissRequest = {
passwordDialogVisible = false
},
onConfirm = {
if (securityKey.password?.isNotEmpty() == true) {
securityKey.password = passwordValue
save(account, viewModel, securityKey)
passwordDialogVisible = false
}
}
)
}
private fun save(
account: Account,
viewModel: AccountViewModel,
securityKey: FeverSecurityKey,
) {
account.id?.let {
viewModel.update(it) {
this.securityKey = securityKey.toString()
}
}
}

View File

@ -23,6 +23,7 @@
<string name="deny">拒绝</string>
<string name="defaults">默认</string>
<string name="unknown">未知</string>
<string name="empty"></string>
<string name="back">返回</string>
<string name="go_to">转到</string>
<string name="settings">设置</string>
@ -346,4 +347,8 @@
<string name="switch_account">切换账户</string>
<string name="add">添加</string>
<string name="accounts_tips">在订阅源页面中点击帐户名称来切换它们。</string>
<string name="server_url">服务器地址</string>
<string name="username">用户名</string>
<string name="password">密码</string>
<string name="connection">连接信息</string>
</resources>

View File

@ -27,6 +27,7 @@
<string name="deny">Deny</string>
<string name="defaults">Default</string>
<string name="unknown">Unknown</string>
<string name="empty">Empty</string>
<string name="back">Back</string>
<string name="go_to">Go to</string>
<string name="settings">Settings</string>
@ -37,7 +38,7 @@
<string name="already_subscribed">Already subscribed</string>
<string name="clear">Clear</string>
<string name="paste">Paste</string>
<string name="feed_or_site_url">Feed or URL</string>
<string name="feed_or_site_url">Feed or Site URL</string>
<string name="import_from_opml">Import from OPML</string>
<string name="preset">Preset</string>
<string name="selected">Selected</string>
@ -382,7 +383,11 @@
<string name="clear_all_articles_toast">All articles from this account have been cleared</string>
<string name="delete_account_toast">This account has been deleted</string>
<string name="synchronous_tips">Restart is required for changes to take effect.</string>
<string name="switch_account">Switch Account</string>
<string name="switch_account">Switch</string>
<string name="add">Add</string>
<string name="accounts_tips">Click the account name on the feed page to switch them.</string>
<string name="server_url">Server URL</string>
<string name="username">Username</string>
<string name="password">Password</string>
<string name="connection">Connection</string>
</resources>