From 3642e22fbb0db3d887316cad538e34ef41fa9efc Mon Sep 17 00:00:00 2001 From: Ash Date: Sun, 1 Jan 2023 07:57:38 +0800 Subject: [PATCH] Support Fever API (#291) --- .../4.json | 371 ++++++++++++++++++ app/src/main/java/me/ash/reader/RYApp.kt | 4 +- .../java/me/ash/reader/data/dao/ArticleDao.kt | 21 +- .../java/me/ash/reader/data/dao/FeedDao.kt | 4 +- .../java/me/ash/reader/data/dao/GroupDao.kt | 4 +- .../ash/reader/data/model/account/Account.kt | 3 + .../data/model/account/security/DESUtils.kt | 34 ++ .../account/security/FeverSecurityKey.kt | 22 ++ .../security/GoogleReaderSecurityKey.kt | 22 ++ .../account/security/LocalSecurityKey.kt | 10 + .../model/account/security/SecurityKey.kt | 21 + .../ash/reader/data/module/RetrofitModule.kt | 14 - .../me/ash/reader/data/provider/BaseAPI.kt | 20 + .../reader/data/provider/fever/FeverAPI.kt | 117 ++++++ .../fever/FeverDTO.kt} | 151 ++++--- .../data/repository/AbstractRssRepository.kt | 125 +++++- .../data/repository/AccountRepository.kt | 2 +- .../data/repository/FeverRssRepository.kt | 231 +++++++++++ .../data/repository/LocalRssRepository.kt | 130 +----- .../reader/data/repository/OpmlRepository.kt | 6 +- .../reader/data/repository/RYRepository.kt | 6 +- .../ash/reader/data/repository/RssHelper.kt | 2 +- .../reader/data/repository/RssRepository.kt | 9 +- .../ash/reader/data/repository/SyncWorker.kt | 2 +- .../reader/data/source/FeverApiDataSource.kt | 60 --- .../data/source/GoogleReaderApiDataSource.kt | 45 --- ...mlLocalDataSource.kt => OPMLDataSource.kt} | 2 +- .../me/ash/reader/data/source/RYDatabase.kt | 16 +- .../ui/component/base/ClipboardTextField.kt | 2 + .../ui/component/base/RYOutlineTextField.kt | 25 +- .../reader/ui/component/base/RYTextField.kt | 25 +- .../ui/component/base/TextFieldDialog.kt | 2 + .../java/me/ash/reader/ui/ext/NumberExt.kt | 2 + .../java/me/ash/reader/ui/ext/StringExt.kt | 18 +- .../ui/page/home/feeds/FeedOptionView.kt | 20 +- .../reader/ui/page/home/feeds/FeedsPage.kt | 18 +- .../feeds/drawer/feed/FeedOptionDrawer.kt | 1 + .../feeds/drawer/feed/FeedOptionViewModel.kt | 2 +- .../feeds/subscribe/SubscribeViewModel.kt | 2 +- .../ash/reader/ui/page/home/flow/FlowPage.kt | 3 +- .../ui/page/home/reading/ReadingViewModel.kt | 5 +- .../settings/accounts/AccountDetailsPage.kt | 46 +-- .../settings/accounts/AccountViewModel.kt | 17 +- .../page/settings/accounts/AddAccountsPage.kt | 13 +- .../addition/AddFeverAccountDialog.kt | 145 +++++++ .../addition/AddLocalAccountDialog.kt | 20 +- .../accounts/connection/AccountConnection.kt | 36 ++ .../accounts/connection/FeverConnection.kt | 132 +++++++ app/src/main/res/values-zh-rCN/strings.xml | 5 + app/src/main/res/values/strings.xml | 9 +- 50 files changed, 1601 insertions(+), 401 deletions(-) create mode 100644 app/schemas/me.ash.reader.data.source.RYDatabase/4.json create mode 100644 app/src/main/java/me/ash/reader/data/model/account/security/DESUtils.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/account/security/FeverSecurityKey.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/account/security/GoogleReaderSecurityKey.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/account/security/LocalSecurityKey.kt create mode 100644 app/src/main/java/me/ash/reader/data/model/account/security/SecurityKey.kt create mode 100644 app/src/main/java/me/ash/reader/data/provider/BaseAPI.kt create mode 100644 app/src/main/java/me/ash/reader/data/provider/fever/FeverAPI.kt rename app/src/main/java/me/ash/reader/data/{source/FeverApiDto.kt => provider/fever/FeverDTO.kt} (52%) create mode 100644 app/src/main/java/me/ash/reader/data/repository/FeverRssRepository.kt delete mode 100644 app/src/main/java/me/ash/reader/data/source/FeverApiDataSource.kt delete mode 100644 app/src/main/java/me/ash/reader/data/source/GoogleReaderApiDataSource.kt rename app/src/main/java/me/ash/reader/data/source/{OpmlLocalDataSource.kt => OPMLDataSource.kt} (99%) create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFeverAccountDialog.kt create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt create mode 100644 app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FeverConnection.kt diff --git a/app/schemas/me.ash.reader.data.source.RYDatabase/4.json b/app/schemas/me.ash.reader.data.source.RYDatabase/4.json new file mode 100644 index 00000000..e3778bd4 --- /dev/null +++ b/app/schemas/me.ash.reader.data.source.RYDatabase/4.json @@ -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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/me/ash/reader/RYApp.kt b/app/src/main/java/me/ash/reader/RYApp.kt index 5fee0baf..7c7ef00c 100644 --- a/app/src/main/java/me/ash/reader/RYApp.kt +++ b/app/src/main/java/me/ash/reader/RYApp.kt @@ -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 diff --git a/app/src/main/java/me/ash/reader/data/dao/ArticleDao.kt b/app/src/main/java/me/ash/reader/data/dao/ArticleDao.kt index 8e031994..3a15a031 100644 --- a/app/src/main/java/me/ash/reader/data/dao/ArticleDao.kt +++ b/app/src/main/java/me/ash/reader/data/dao/ArticleDao.kt @@ -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
): List + suspend fun insertList(articles: List
) @Update suspend fun update(vararg article: Article) diff --git a/app/src/main/java/me/ash/reader/data/dao/FeedDao.kt b/app/src/main/java/me/ash/reader/data/dao/FeedDao.kt index 757093c0..2e978b01 100644 --- a/app/src/main/java/me/ash/reader/data/dao/FeedDao.kt +++ b/app/src/main/java/me/ash/reader/data/dao/FeedDao.kt @@ -87,8 +87,8 @@ interface FeedDao { ) suspend fun queryByLink(accountId: Int, url: String): List - @Insert - suspend fun insert(feed: Feed): Long + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg feed: Feed) @Insert suspend fun insertList(feeds: List): List diff --git a/app/src/main/java/me/ash/reader/data/dao/GroupDao.kt b/app/src/main/java/me/ash/reader/data/dao/GroupDao.kt index 3328a05f..fe478de1 100644 --- a/app/src/main/java/me/ash/reader/data/dao/GroupDao.kt +++ b/app/src/main/java/me/ash/reader/data/dao/GroupDao.kt @@ -58,8 +58,8 @@ interface GroupDao { ) suspend fun queryAll(accountId: Int): List - @Insert - suspend fun insert(group: Group): Long + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(vararg group: Group) @Update suspend fun update(vararg group: Group) diff --git a/app/src/main/java/me/ash/reader/data/model/account/Account.kt b/app/src/main/java/me/ash/reader/data/model/account/Account.kt index e2aa508f..766fafed 100644 --- a/app/src/main/java/me/ash/reader/data/model/account/Account.kt +++ b/app/src/main/java/me/ash/reader/data/model/account/Account.kt @@ -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, ) diff --git a/app/src/main/java/me/ash/reader/data/model/account/security/DESUtils.kt b/app/src/main/java/me/ash/reader/data/model/account/security/DESUtils.kt new file mode 100644 index 00000000..8d3329a7 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/security/DESUtils.kt @@ -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))) + } + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/account/security/FeverSecurityKey.kt b/app/src/main/java/me/ash/reader/data/model/account/security/FeverSecurityKey.kt new file mode 100644 index 00000000..ac1ec23c --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/security/FeverSecurityKey.kt @@ -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 + } + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/account/security/GoogleReaderSecurityKey.kt b/app/src/main/java/me/ash/reader/data/model/account/security/GoogleReaderSecurityKey.kt new file mode 100644 index 00000000..f7686806 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/security/GoogleReaderSecurityKey.kt @@ -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 + } + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/account/security/LocalSecurityKey.kt b/app/src/main/java/me/ash/reader/data/model/account/security/LocalSecurityKey.kt new file mode 100644 index 00000000..224ca20a --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/security/LocalSecurityKey.kt @@ -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 { + + } + } +} diff --git a/app/src/main/java/me/ash/reader/data/model/account/security/SecurityKey.kt b/app/src/main/java/me/ash/reader/data/model/account/security/SecurityKey.kt new file mode 100644 index 00000000..78f564be --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/model/account/security/SecurityKey.kt @@ -0,0 +1,21 @@ +package me.ash.reader.data.model.account.security + +import com.google.gson.Gson + +abstract class SecurityKey { + + fun decode(value: String?, classOfT: Class): 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() + } +} diff --git a/app/src/main/java/me/ash/reader/data/module/RetrofitModule.kt b/app/src/main/java/me/ash/reader/data/module/RetrofitModule.kt index 0dcea176..b1f33a71 100644 --- a/app/src/main/java/me/ash/reader/data/module/RetrofitModule.kt +++ b/app/src/main/java/me/ash/reader/data/module/RetrofitModule.kt @@ -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() } diff --git a/app/src/main/java/me/ash/reader/data/provider/BaseAPI.kt b/app/src/main/java/me/ash/reader/data/provider/BaseAPI.kt new file mode 100644 index 00000000..b938bbed --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/provider/BaseAPI.kt @@ -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 toDTO(jsonStr: String): T = + gson.fromJson(jsonStr, T::class.java)!! +} diff --git a/app/src/main/java/me/ash/reader/data/provider/fever/FeverAPI.kt b/app/src/main/java/me/ash/reader/data/provider/fever/FeverAPI.kt new file mode 100644 index 00000000..f269fb27 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/provider/fever/FeverAPI.kt @@ -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 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): 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(null).auth) + + suspend fun getApiVersion(): Long = + postRequest>(null)["api_version"] as Long? + ?: throw Exception("Unable to get version") + + suspend fun getGroups(): FeverDTO.Groups = + postRequest("groups").apply { checkAuth(auth) } + + suspend fun getFeeds(): FeverDTO.Feeds = + postRequest("feeds").apply { checkAuth(auth) } + + suspend fun getFavicons(): FeverDTO.Favicons = + postRequest("favicons").apply { checkAuth(auth) } + + suspend fun getItems(): FeverDTO.Items = + postRequest("items").apply { checkAuth(auth) } + + suspend fun getItemsSince(id: String): FeverDTO.Items = + postRequest("items&since_id=$id").apply { checkAuth(auth) } + + suspend fun getItemsMax(id: String): FeverDTO.Items = + postRequest("items&max_id=$id").apply { checkAuth(auth) } + + suspend fun getItemsWith(ids: List): FeverDTO.Items = + if (ids.size > 50) throw Exception("Too many ids") + else postRequest("items&with_ids=${ids.joinToString(",")}").apply { checkAuth(auth) } + + suspend fun getLinks(): FeverDTO.Links = + postRequest("links").apply { checkAuth(auth) } + + suspend fun getLinksWith(offset: Long, days: Long, page: Long): FeverDTO.Links = + postRequest("links&offset=$offset&range=$days&page=$page").apply { checkAuth(auth) } + + suspend fun getUnreadItems(): FeverDTO.ItemsByUnread = + postRequest("unread_item_ids").apply { checkAuth(auth) } + + suspend fun getSavedItems(): FeverDTO.ItemsByStarred = + postRequest("saved_item_ids").apply { checkAuth(auth) } + + suspend fun markItem(status: FeverDTO.StatusEnum, id: String): FeverDTO.Common = + postRequest("&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("&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 = 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) + } + } + } +} diff --git a/app/src/main/java/me/ash/reader/data/source/FeverApiDto.kt b/app/src/main/java/me/ash/reader/data/provider/fever/FeverDTO.kt similarity index 52% rename from app/src/main/java/me/ash/reader/data/source/FeverApiDto.kt rename to app/src/main/java/me/ash/reader/data/provider/fever/FeverDTO.kt index 5c01ca2e..25414ecd 100644 --- a/app/src/main/java/me/ash/reader/data/source/FeverApiDto.kt +++ b/app/src/main/java/me/ash/reader/data/provider/fever/FeverDTO.kt @@ -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, - val feeds_groups: List, + data class Feeds( + val api_version: Int?, + val auth: Int?, + val last_refreshed_on_time: Long?, + val feeds: List?, + val feeds_groups: List?, ) 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, - val feeds_groups: List, + val api_version: Int?, + val auth: Int?, + val last_refreshed_on_time: Long?, + val groups: List?, + val feeds_groups: List?, ) 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, + ) + + 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, + val api_version: Int?, + val auth: Int?, + val last_refreshed_on_time: Long?, + val total_items: Int?, + val items: List?, ) 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?, + ) + + 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?, ) /** @@ -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?, ) -} \ No newline at end of file + + enum class StatusEnum(val value: String) { + Read("read"), + Unread("unread"), + Saved("saved"), + Unsaved("unsaved"), + } +} diff --git a/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt b/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt index 29db84b7..02e5e893 100644 --- a/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/AbstractRssRepository.kt @@ -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
) + open suspend fun validCredentials(): Boolean = true - abstract suspend fun addGroup(name: String): String + open suspend fun subscribe(feed: Feed, articles: List
) { + 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)) } } diff --git a/app/src/main/java/me/ash/reader/data/repository/AccountRepository.kt b/app/src/main/java/me/ash/reader/data/repository/AccountRepository.kt index 7ec03e5f..e6f10758 100644 --- a/app/src/main/java/me/ash/reader/data/repository/AccountRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/AccountRepository.kt @@ -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) { diff --git a/app/src/main/java/me/ash/reader/data/repository/FeverRssRepository.kt b/app/src/main/java/me/ash/reader/data/repository/FeverRssRepository.kt new file mode 100644 index 00000000..5a8653d7 --- /dev/null +++ b/app/src/main/java/me/ash/reader/data/repository/FeverRssRepository.kt @@ -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
) { + 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() + 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() + ) + } +} diff --git a/app/src/main/java/me/ash/reader/data/repository/LocalRssRepository.kt b/app/src/main/java/me/ash/reader/data/repository/LocalRssRepository.kt index 48c10dd1..d880b60b 100644 --- a/app/src/main/java/me/ash/reader/data/repository/LocalRssRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/LocalRssRepository.kt @@ -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
) { - 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 +) diff --git a/app/src/main/java/me/ash/reader/data/repository/OpmlRepository.kt b/app/src/main/java/me/ash/reader/data/repository/OpmlRepository.kt index aae44cd3..98ff99ab 100644 --- a/app/src/main/java/me/ash/reader/data/repository/OpmlRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/OpmlRepository.kt @@ -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) diff --git a/app/src/main/java/me/ash/reader/data/repository/RYRepository.kt b/app/src/main/java/me/ash/reader/data/repository/RYRepository.kt index c65ef98a..a3d4af17 100644 --- a/app/src/main/java/me/ash/reader/data/repository/RYRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/RYRepository.kt @@ -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() diff --git a/app/src/main/java/me/ash/reader/data/repository/RssHelper.kt b/app/src/main/java/me/ash/reader/data/repository/RssHelper.kt index a8a084d3..30d84d55 100644 --- a/app/src/main/java/me/ash/reader/data/repository/RssHelper.kt +++ b/app/src/main/java/me/ash/reader/data/repository/RssHelper.kt @@ -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 diff --git a/app/src/main/java/me/ash/reader/data/repository/RssRepository.kt b/app/src/main/java/me/ash/reader/data/repository/RssRepository.kt index 3ccece98..b536e521 100644 --- a/app/src/main/java/me/ash/reader/data/repository/RssRepository.kt +++ b/app/src/main/java/me/ash/reader/data/repository/RssRepository.kt @@ -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 } } diff --git a/app/src/main/java/me/ash/reader/data/repository/SyncWorker.kt b/app/src/main/java/me/ash/reader/data/repository/SyncWorker.kt index f592f732..f415548d 100644 --- a/app/src/main/java/me/ash/reader/data/repository/SyncWorker.kt +++ b/app/src/main/java/me/ash/reader/data/repository/SyncWorker.kt @@ -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() } } diff --git a/app/src/main/java/me/ash/reader/data/source/FeverApiDataSource.kt b/app/src/main/java/me/ash/reader/data/source/FeverApiDataSource.kt deleted file mode 100644 index 7676f3d6..00000000 --- a/app/src/main/java/me/ash/reader/data/source/FeverApiDataSource.kt +++ /dev/null @@ -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 - - @Multipart - @POST("fever.php/?api=&groups=") - fun groups(@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call - - @Multipart - @POST("fever.php/?api=&items=") - fun itemsBySince( - @Query("since_id") since: Long, - @Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody(), - ): Call - - @Multipart - @POST("fever.php/?api=&unread_item_ids=") - fun itemsByUnread(@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call - - @Multipart - @POST("fever.php/?api=&saved_item_ids=") - fun itemsByStarred(@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call - - @Multipart - @POST("fever.php/?api=&items=") - fun itemsByIds( - @Query("with_ids") ids: String, - @Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody(), - ): Call - - 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 - } - } - } - } -} diff --git a/app/src/main/java/me/ash/reader/data/source/GoogleReaderApiDataSource.kt b/app/src/main/java/me/ash/reader/data/source/GoogleReaderApiDataSource.kt deleted file mode 100644 index 7684b72e..00000000 --- a/app/src/main/java/me/ash/reader/data/source/GoogleReaderApiDataSource.kt +++ /dev/null @@ -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 - - @Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}") - @POST("reader/api/0/subscription/list?output=json") - fun subscriptionList(): Call - - @Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}") - @POST("reader/api/0/unread-count?output=json") - fun unreadCount(): Call - - @Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}") - @POST("reader/api/0/tag/list?output=json") - fun tagList(): Call - - @Headers("Authorization:GoogleLogin auth=${"Ashinch/678592edaf9145e5b27068d9dc3afc41494ba54e"}") - @POST("reader/api/0/stream/contents/reading-list") - fun readingList(): Call - - 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 - } - } - } - } -} diff --git a/app/src/main/java/me/ash/reader/data/source/OpmlLocalDataSource.kt b/app/src/main/java/me/ash/reader/data/source/OPMLDataSource.kt similarity index 99% rename from app/src/main/java/me/ash/reader/data/source/OpmlLocalDataSource.kt rename to app/src/main/java/me/ash/reader/data/source/OPMLDataSource.kt index 7d653eab..1138e0a9 100644 --- a/app/src/main/java/me/ash/reader/data/source/OpmlLocalDataSource.kt +++ b/app/src/main/java/me/ash/reader/data/source/OPMLDataSource.kt @@ -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 diff --git a/app/src/main/java/me/ash/reader/data/source/RYDatabase.kt b/app/src/main/java/me/ash/reader/data/source/RYDatabase.kt index 030b655f..e76626b2 100644 --- a/app/src/main/java/me/ash/reader/data/source/RYDatabase.kt +++ b/app/src/main/java/me/ash/reader/data/source/RYDatabase.kt @@ -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() + ) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/component/base/ClipboardTextField.kt b/app/src/main/java/me/ash/reader/ui/component/base/ClipboardTextField.kt index 8c0ce609..5b42fd5f 100644 --- a/app/src/main/java/me/ash/reader/ui/component/base/ClipboardTextField.kt +++ b/app/src/main/java/me/ash/reader/ui/component/base/ClipboardTextField.kt @@ -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) diff --git a/app/src/main/java/me/ash/reader/ui/component/base/RYOutlineTextField.kt b/app/src/main/java/me/ash/reader/ui/component/base/RYOutlineTextField.kt index c5eba2d9..9d627397 100644 --- a/app/src/main/java/me/ash/reader/ui/component/base/RYOutlineTextField.kt +++ b/app/src/main/java/me/ash/reader/ui/component/base/RYOutlineTextField.kt @@ -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), ) } diff --git a/app/src/main/java/me/ash/reader/ui/component/base/RYTextField.kt b/app/src/main/java/me/ash/reader/ui/component/base/RYTextField.kt index 15f2b17f..4d054457 100644 --- a/app/src/main/java/me/ash/reader/ui/component/base/RYTextField.kt +++ b/app/src/main/java/me/ash/reader/ui/component/base/RYTextField.kt @@ -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), ) } diff --git a/app/src/main/java/me/ash/reader/ui/component/base/TextFieldDialog.kt b/app/src/main/java/me/ash/reader/ui/component/base/TextFieldDialog.kt index 206b0620..37db150e 100644 --- a/app/src/main/java/me/ash/reader/ui/component/base/TextFieldDialog.kt +++ b/app/src/main/java/me/ash/reader/ui/component/base/TextFieldDialog.kt @@ -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, diff --git a/app/src/main/java/me/ash/reader/ui/ext/NumberExt.kt b/app/src/main/java/me/ash/reader/ui/ext/NumberExt.kt index b40ead2a..ea148a0a 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/NumberExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/NumberExt.kt @@ -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 diff --git a/app/src/main/java/me/ash/reader/ui/ext/StringExt.kt b/app/src/main/java/me/ash/reader/ui/ext/StringExt.kt index eb8aa3e0..ef6bc251 100644 --- a/app/src/main/java/me/ash/reader/ui/ext/StringExt.kt +++ b/app/src/main/java/me/ash/reader/ui/ext/StringExt.kt @@ -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) -} \ No newline at end of file +} + +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') diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt index ef5cbdd4..ca0439cc 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedOptionView.kt @@ -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)) } } diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt index 427eef00..8da88495 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/FeedsPage.kt @@ -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 = { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt index 1d2f9681..65216c07 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionDrawer.kt @@ -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 = { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt index 88856ef5..864e6296 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/drawer/feed/FeedOptionViewModel.kt @@ -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 diff --git a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt index 81b39e09..8f7e51f7 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/feeds/subscribe/SubscribeViewModel.kt @@ -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() { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt index b57a11de..8712cdbb 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/flow/FlowPage.kt @@ -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) { diff --git a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt index 0d55cfc3..876d03a3 100644 --- a/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/home/reading/ReadingViewModel.kt @@ -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, ) } } diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountDetailsPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountDetailsPage.kt index feb5265e..f10b90df 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountDetailsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountDetailsPage.kt @@ -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()) } diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt index 279529b3..edbe3b64 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AccountViewModel.kt @@ -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) + } } } } diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt index 0c1db11a..cd3cbbe8 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/AddAccountsPage.kt @@ -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 diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFeverAccountDialog.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFeverAccountDialog.kt new file mode 100644 index 00000000..a2f0c341 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddFeverAccountDialog.kt @@ -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)) + } + }, + ) +} diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddLocalAccountDialog.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddLocalAccountDialog.kt index 9fe8b8ac..36cf82c8 100644 --- a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddLocalAccountDialog.kt +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/addition/AddLocalAccountDialog.kt @@ -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 + } } } } diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt new file mode 100644 index 00000000..1899b3ab --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/AccountConnection.kt @@ -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)) + } +} diff --git a/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FeverConnection.kt b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FeverConnection.kt new file mode 100644 index 00000000..c1dd8385 --- /dev/null +++ b/app/src/main/java/me/ash/reader/ui/page/settings/accounts/connection/FeverConnection.kt @@ -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() + } + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 99d5f693..901d5b75 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -23,6 +23,7 @@ 拒绝 默认 未知 + 返回 转到 设置 @@ -346,4 +347,8 @@ 切换账户 添加 在订阅源页面中点击帐户名称来切换它们。 + 服务器地址 + 用户名 + 密码 + 连接信息 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 86960206..044a5a78 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -27,6 +27,7 @@ Deny Default Unknown + Empty Back Go to Settings @@ -37,7 +38,7 @@ Already subscribed Clear Paste - Feed or URL + Feed or Site URL Import from OPML Preset Selected @@ -382,7 +383,11 @@ All articles from this account have been cleared This account has been deleted Restart is required for changes to take effect. - Switch Account + Switch Add Click the account name on the feed page to switch them. + Server URL + Username + Password + Connection