Support Fever API (#291)
This commit is contained in:
parent
ba745e8ab1
commit
3642e22fbb
371
app/schemas/me.ash.reader.data.source.RYDatabase/4.json
Normal file
371
app/schemas/me.ash.reader.data.source.RYDatabase/4.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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>
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
)
|
||||
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
|
20
app/src/main/java/me/ash/reader/data/provider/BaseAPI.kt
Normal file
20
app/src/main/java/me/ash/reader/data/provider/BaseAPI.kt
Normal 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)!!
|
||||
}
|
117
app/src/main/java/me/ash/reader/data/provider/fever/FeverAPI.kt
Normal file
117
app/src/main/java/me/ash/reader/data/provider/fever/FeverAPI.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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"),
|
||||
}
|
||||
}
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
@ -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
|
||||
)
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
|
@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
@ -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),
|
||||
)
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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 = {
|
||||
|
@ -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
|
||||
|
@ -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() {
|
||||
|
@ -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) {
|
||||
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
|
Loading…
x
Reference in New Issue
Block a user