parent
							
								
									f19d044181
								
							
						
					
					
						commit
						5b22b46912
					
				| @ -7,11 +7,18 @@ import me.ash.reader.ui.ext.showToastLong | ||||
| import java.lang.Thread.UncaughtExceptionHandler | ||||
| import kotlin.system.exitProcess | ||||
| 
 | ||||
| /** | ||||
|  * The uncaught exception handler for the application. | ||||
|  */ | ||||
| class CrashHandler(private val context: Context) : UncaughtExceptionHandler { | ||||
| 
 | ||||
|     init { | ||||
|         Thread.setDefaultUncaughtExceptionHandler(this) | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Catch all uncaught exception and log it. | ||||
|      */ | ||||
|     override fun uncaughtException(p0: Thread, p1: Throwable) { | ||||
|         Looper.myLooper() ?: Looper.prepare() | ||||
|         context.showToastLong(p1.message) | ||||
|  | ||||
| @ -10,14 +10,18 @@ import androidx.profileinstaller.ProfileInstallerInitializer | ||||
| import coil.ImageLoader | ||||
| import coil.compose.LocalImageLoader | ||||
| import dagger.hilt.android.AndroidEntryPoint | ||||
| import me.ash.reader.data.preference.LanguagesPreference | ||||
| import me.ash.reader.data.preference.SettingsProvider | ||||
| import me.ash.reader.data.model.preference.LanguagesPreference | ||||
| import me.ash.reader.data.model.preference.SettingsProvider | ||||
| import me.ash.reader.ui.ext.languages | ||||
| import me.ash.reader.ui.page.common.HomeEntry | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| /** | ||||
|  * The Single-Activity Architecture. | ||||
|  */ | ||||
| @AndroidEntryPoint | ||||
| class MainActivity : ComponentActivity() { | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var imageLoader: ImageLoader | ||||
| 
 | ||||
|  | ||||
| @ -9,8 +9,9 @@ import dagger.hilt.android.HiltAndroidApp | ||||
| import kotlinx.coroutines.CoroutineDispatcher | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.launch | ||||
| import kotlinx.coroutines.withContext | ||||
| import me.ash.reader.data.module.ApplicationScope | ||||
| import me.ash.reader.data.module.DispatcherDefault | ||||
| 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.RYDatabase | ||||
| @ -21,16 +22,23 @@ import org.conscrypt.Conscrypt | ||||
| import java.security.Security | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| /** | ||||
|  * The Application class, where the Dagger components is generated. | ||||
|  */ | ||||
| @HiltAndroidApp | ||||
| class RYApp : Application(), Configuration.Provider { | ||||
| 
 | ||||
|     /** | ||||
|      * From: [Feeder](https://gitlab.com/spacecowboy/Feeder). | ||||
|      * | ||||
|      * Install Conscrypt to handle TLSv1.3 pre Android10. | ||||
|      */ | ||||
|     init { | ||||
|         // From: https://gitlab.com/spacecowboy/Feeder | ||||
|         // Install Conscrypt to handle TLSv1.3 pre Android10 | ||||
|         Security.insertProviderAt(Conscrypt.newProvider(), 1) | ||||
|     } | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var RYDatabase: RYDatabase | ||||
|     lateinit var ryDatabase: RYDatabase | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var workerFactory: HiltWorkerFactory | ||||
| @ -39,7 +47,7 @@ class RYApp : Application(), Configuration.Provider { | ||||
|     lateinit var workManager: WorkManager | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var RYNetworkDataSource: RYNetworkDataSource | ||||
|     lateinit var ryNetworkDataSource: RYNetworkDataSource | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var opmlLocalDataSource: OpmlLocalDataSource | ||||
| @ -73,8 +81,8 @@ class RYApp : Application(), Configuration.Provider { | ||||
|     lateinit var applicationScope: CoroutineScope | ||||
| 
 | ||||
|     @Inject | ||||
|     @DispatcherDefault | ||||
|     lateinit var dispatcherDefault: CoroutineDispatcher | ||||
|     @IODispatcher | ||||
|     lateinit var ioDispatcher: CoroutineDispatcher | ||||
| 
 | ||||
|     @Inject | ||||
|     lateinit var okHttpClient: OkHttpClient | ||||
| @ -82,27 +90,40 @@ class RYApp : Application(), Configuration.Provider { | ||||
|     @Inject | ||||
|     lateinit var imageLoader: ImageLoader | ||||
| 
 | ||||
|     /** | ||||
|      * When the application startup. | ||||
|      * | ||||
|      * 1. Set the uncaught exception handler | ||||
|      * 2. Initialize the default account if there is none | ||||
|      * 3. Synchronize once | ||||
|      * 4. Check for new version | ||||
|      */ | ||||
|     override fun onCreate() { | ||||
|         super.onCreate() | ||||
|         CrashHandler(this) | ||||
|         dataStoreInit() | ||||
|         applicationScope.launch(dispatcherDefault) { | ||||
|         applicationScope.launch { | ||||
|             accountInit() | ||||
|             workerInit() | ||||
|             if (notFdroid) { | ||||
|                 checkUpdate() | ||||
|             } | ||||
|             if (notFdroid) checkUpdate() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun dataStoreInit() { | ||||
|     } | ||||
|     /** | ||||
|      * Override the [Configuration.Builder] to provide the [HiltWorkerFactory]. | ||||
|      */ | ||||
|     override fun getWorkManagerConfiguration(): Configuration = | ||||
|         Configuration.Builder() | ||||
|             .setWorkerFactory(workerFactory) | ||||
|             .setMinimumLoggingLevel(android.util.Log.DEBUG) | ||||
|             .build() | ||||
| 
 | ||||
|     private suspend fun accountInit() { | ||||
|         if (accountRepository.isNoAccount()) { | ||||
|             val account = accountRepository.addDefaultAccount() | ||||
|             applicationContext.dataStore.put(DataStoreKeys.CurrentAccountId, account.id!!) | ||||
|             applicationContext.dataStore.put(DataStoreKeys.CurrentAccountType, account.type) | ||||
|         withContext(ioDispatcher) { | ||||
|             if (accountRepository.isNoAccount()) { | ||||
|                 val account = accountRepository.addDefaultAccount() | ||||
|                 applicationContext.dataStore.put(DataStoreKeys.CurrentAccountId, account.id!!) | ||||
|                 applicationContext.dataStore.put(DataStoreKeys.CurrentAccountType, account.type.id) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
| @ -111,17 +132,11 @@ class RYApp : Application(), Configuration.Provider { | ||||
|     } | ||||
| 
 | ||||
|     private suspend fun checkUpdate() { | ||||
|         applicationContext.getLatestApk().let { | ||||
|             if (it.exists()) { | ||||
|                 it.del() | ||||
|         withContext(ioDispatcher) { | ||||
|             applicationContext.getLatestApk().let { | ||||
|                 if (it.exists()) it.del() | ||||
|             } | ||||
|         } | ||||
|         ryRepository.checkUpdate(showToast = false) | ||||
|     } | ||||
| 
 | ||||
|     override fun getWorkManagerConfiguration(): Configuration = | ||||
|         Configuration.Builder() | ||||
|             .setWorkerFactory(workerFactory) | ||||
|             .setMinimumLoggingLevel(android.util.Log.DEBUG) | ||||
|             .build() | ||||
| } | ||||
|  | ||||
| @ -1,6 +1,12 @@ | ||||
| package me.ash.reader.data.constant | ||||
| 
 | ||||
| /** | ||||
|  * The tonal elevation tokens. | ||||
|  * | ||||
|  * @see androidx.compose.material3.tokens.ElevationTokens | ||||
|  */ | ||||
| object ElevationTokens { | ||||
| 
 | ||||
|     const val Level0 = 0 | ||||
|     const val Level1 = 1 | ||||
|     const val Level2 = 3 | ||||
|  | ||||
| @ -1,10 +1,11 @@ | ||||
| package me.ash.reader.data.dao | ||||
| 
 | ||||
| import androidx.room.* | ||||
| import me.ash.reader.data.entity.Account | ||||
| import me.ash.reader.data.model.account.Account | ||||
| 
 | ||||
| @Dao | ||||
| interface AccountDao { | ||||
| 
 | ||||
|     @Query( | ||||
|         """ | ||||
|         SELECT * FROM account | ||||
|  | ||||
| @ -3,13 +3,14 @@ package me.ash.reader.data.dao | ||||
| import androidx.paging.PagingSource | ||||
| import androidx.room.* | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import me.ash.reader.data.entity.Article | ||||
| import me.ash.reader.data.entity.ArticleWithFeed | ||||
| import me.ash.reader.data.model.ImportantCount | ||||
| import me.ash.reader.data.model.article.Article | ||||
| import me.ash.reader.data.model.article.ArticleWithFeed | ||||
| import me.ash.reader.data.model.feed.ImportantNum | ||||
| import java.util.* | ||||
| 
 | ||||
| @Dao | ||||
| interface ArticleDao { | ||||
| 
 | ||||
|     @Transaction | ||||
|     @Query( | ||||
|         """ | ||||
| @ -298,8 +299,8 @@ interface ArticleDao { | ||||
|     ) | ||||
|     fun queryImportantCountWhenIsUnread( | ||||
|         accountId: Int, | ||||
|         isUnread: Boolean | ||||
|     ): Flow<List<ImportantCount>> | ||||
|         isUnread: Boolean, | ||||
|     ): Flow<List<ImportantNum>> | ||||
| 
 | ||||
|     @Transaction | ||||
|     @Query( | ||||
| @ -315,8 +316,8 @@ interface ArticleDao { | ||||
|     ) | ||||
|     fun queryImportantCountWhenIsStarred( | ||||
|         accountId: Int, | ||||
|         isStarred: Boolean | ||||
|     ): Flow<List<ImportantCount>> | ||||
|         isStarred: Boolean, | ||||
|     ): Flow<List<ImportantNum>> | ||||
| 
 | ||||
|     @Transaction | ||||
|     @Query( | ||||
| @ -329,7 +330,7 @@ interface ArticleDao { | ||||
|         GROUP BY a.feedId | ||||
|         """ | ||||
|     ) | ||||
|     fun queryImportantCountWhenIsAll(accountId: Int): Flow<List<ImportantCount>> | ||||
|     fun queryImportantCountWhenIsAll(accountId: Int): Flow<List<ImportantNum>> | ||||
| 
 | ||||
|     @Transaction | ||||
|     @Query( | ||||
| @ -352,7 +353,7 @@ interface ArticleDao { | ||||
|     ) | ||||
|     fun queryArticleWithFeedWhenIsStarred( | ||||
|         accountId: Int, | ||||
|         isStarred: Boolean | ||||
|         isStarred: Boolean, | ||||
|     ): PagingSource<Int, ArticleWithFeed> | ||||
| 
 | ||||
|     @Transaction | ||||
| @ -366,7 +367,7 @@ interface ArticleDao { | ||||
|     ) | ||||
|     fun queryArticleWithFeedWhenIsUnread( | ||||
|         accountId: Int, | ||||
|         isUnread: Boolean | ||||
|         isUnread: Boolean, | ||||
|     ): PagingSource<Int, ArticleWithFeed> | ||||
| 
 | ||||
|     @Transaction | ||||
| @ -444,7 +445,7 @@ interface ArticleDao { | ||||
|     ) | ||||
|     fun queryArticleWithFeedByFeedIdWhenIsAll( | ||||
|         accountId: Int, | ||||
|         feedId: String | ||||
|         feedId: String, | ||||
|     ): PagingSource<Int, ArticleWithFeed> | ||||
| 
 | ||||
|     @Transaction | ||||
|  | ||||
| @ -1,10 +1,11 @@ | ||||
| package me.ash.reader.data.dao | ||||
| 
 | ||||
| import androidx.room.* | ||||
| import me.ash.reader.data.entity.Feed | ||||
| import me.ash.reader.data.model.feed.Feed | ||||
| 
 | ||||
| @Dao | ||||
| interface FeedDao { | ||||
| 
 | ||||
|     @Query( | ||||
|         """ | ||||
|         UPDATE feed SET groupId = :targetGroupId | ||||
| @ -15,7 +16,7 @@ interface FeedDao { | ||||
|     suspend fun updateTargetGroupIdByGroupId( | ||||
|         accountId: Int, | ||||
|         groupId: String, | ||||
|         targetGroupId: String | ||||
|         targetGroupId: String, | ||||
|     ) | ||||
| 
 | ||||
|     @Query( | ||||
| @ -28,7 +29,7 @@ interface FeedDao { | ||||
|     suspend fun updateIsFullContentByGroupId( | ||||
|         accountId: Int, | ||||
|         groupId: String, | ||||
|         isFullContent: Boolean | ||||
|         isFullContent: Boolean, | ||||
|     ) | ||||
| 
 | ||||
|     @Query( | ||||
| @ -41,7 +42,7 @@ interface FeedDao { | ||||
|     suspend fun updateIsNotificationByGroupId( | ||||
|         accountId: Int, | ||||
|         groupId: String, | ||||
|         isNotification: Boolean | ||||
|         isNotification: Boolean, | ||||
|     ) | ||||
| 
 | ||||
|     @Query( | ||||
|  | ||||
| @ -2,11 +2,12 @@ package me.ash.reader.data.dao | ||||
| 
 | ||||
| import androidx.room.* | ||||
| import kotlinx.coroutines.flow.Flow | ||||
| import me.ash.reader.data.entity.Group | ||||
| import me.ash.reader.data.entity.GroupWithFeed | ||||
| import me.ash.reader.data.model.group.Group | ||||
| import me.ash.reader.data.model.group.GroupWithFeed | ||||
| 
 | ||||
| @Dao | ||||
| interface GroupDao { | ||||
| 
 | ||||
|     @Query( | ||||
|         """ | ||||
|         SELECT * FROM `group` | ||||
|  | ||||
| @ -1,7 +0,0 @@ | ||||
| package me.ash.reader.data.model | ||||
| 
 | ||||
| data class ImportantCount( | ||||
|     val important: Int, | ||||
|     val feedId: String, | ||||
|     val groupId: String, | ||||
| ) | ||||
| @ -1,2 +0,0 @@ | ||||
| package me.ash.reader.data.model | ||||
| 
 | ||||
| @ -1,32 +0,0 @@ | ||||
| package me.ash.reader.data.model | ||||
| 
 | ||||
| class Version(identifiers: List<String>) { | ||||
|     private var major: Int = 0 | ||||
|     private var minor: Int = 0 | ||||
|     private var point: Int = 0 | ||||
| 
 | ||||
|     init { | ||||
|         major = identifiers.getOrNull(0)?.toIntOrNull() ?: 0 | ||||
|         minor = identifiers.getOrNull(1)?.toIntOrNull() ?: 0 | ||||
|         point = identifiers.getOrNull(2)?.toIntOrNull() ?: 0 | ||||
|     } | ||||
| 
 | ||||
|     constructor() : this(listOf()) | ||||
|     constructor(string: String?) : this(string?.split(".") ?: listOf()) | ||||
| 
 | ||||
|     fun whetherNeedUpdate(current: Version, skip: Version): Boolean = this > current && this > skip | ||||
| 
 | ||||
|     operator fun compareTo(target: Version): Int = when { | ||||
|         major > target.major -> 1 | ||||
|         major < target.major -> -1 | ||||
|         minor > target.minor -> 1 | ||||
|         minor < target.minor -> -1 | ||||
|         point > target.point -> 1 | ||||
|         point < target.point -> -1 | ||||
|         else -> 0 | ||||
|     } | ||||
| 
 | ||||
|     override fun toString() = "$major.$minor.$point" | ||||
| } | ||||
| 
 | ||||
| fun String?.toVersion(): Version = Version(this) | ||||
| @ -1,10 +1,14 @@ | ||||
| package me.ash.reader.data.entity | ||||
| package me.ash.reader.data.model.account | ||||
| 
 | ||||
| import androidx.room.ColumnInfo | ||||
| import androidx.room.Entity | ||||
| import androidx.room.PrimaryKey | ||||
| import java.util.* | ||||
| 
 | ||||
| /** | ||||
|  * In the application, at least one account exists and different accounts | ||||
|  * can have the same feeds and articles. | ||||
|  */ | ||||
| @Entity(tableName = "account") | ||||
| data class Account( | ||||
|     @PrimaryKey(autoGenerate = true) | ||||
| @ -12,13 +16,7 @@ data class Account( | ||||
|     @ColumnInfo | ||||
|     var name: String, | ||||
|     @ColumnInfo | ||||
|     var type: Int, | ||||
|     var type: AccountType, | ||||
|     @ColumnInfo | ||||
|     var updateAt: Date? = null, | ||||
| ) { | ||||
|     object Type { | ||||
|         const val LOCAL = 1 | ||||
|         const val FEVER = 2 | ||||
|         const val GOOGLE_READER = 3 | ||||
|     } | ||||
| } | ||||
| ) | ||||
| @ -0,0 +1,45 @@ | ||||
| package me.ash.reader.data.model.account | ||||
| 
 | ||||
| import androidx.room.RoomDatabase | ||||
| import androidx.room.TypeConverter | ||||
| 
 | ||||
| /** | ||||
|  * Each account will specify its local or third-party API type. | ||||
|  */ | ||||
| class AccountType(val id: Int) { | ||||
| 
 | ||||
|     /** | ||||
|      * Make sure the constructed object is valid. | ||||
|      */ | ||||
|     init { | ||||
|         if (id < 1 || id > 3) { | ||||
|             throw IllegalArgumentException("Account type id is not valid.") | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Type of account currently supported. | ||||
|      */ | ||||
|     companion object { | ||||
| 
 | ||||
|         val Local = AccountType(1) | ||||
|         val Fever = AccountType(2) | ||||
|         val GoogleReader = AccountType(3) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Provide [TypeConverter] of [AccountType] for [RoomDatabase]. | ||||
|  */ | ||||
| class AccountTypeConverters { | ||||
| 
 | ||||
|     @TypeConverter | ||||
|     fun toAccountType(id: Int): AccountType { | ||||
|         return AccountType(id) | ||||
|     } | ||||
| 
 | ||||
|     @TypeConverter | ||||
|     fun fromAccountType(accountType: AccountType): Int { | ||||
|         return accountType.id | ||||
|     } | ||||
| } | ||||
| @ -1,8 +1,12 @@ | ||||
| package me.ash.reader.data.entity | ||||
| package me.ash.reader.data.model.article | ||||
| 
 | ||||
| import androidx.room.* | ||||
| import me.ash.reader.data.model.feed.Feed | ||||
| import java.util.* | ||||
| 
 | ||||
| /** | ||||
|  * TODO: Add class description | ||||
|  */ | ||||
| @Entity( | ||||
|     tableName = "article", | ||||
|     foreignKeys = [ForeignKey( | ||||
| @ -43,6 +47,7 @@ data class Article( | ||||
|     @ColumnInfo(defaultValue = "false") | ||||
|     var isReadLater: Boolean = false, | ||||
| ) { | ||||
| 
 | ||||
|     @Ignore | ||||
|     var dateString: String? = null | ||||
| } | ||||
| @ -0,0 +1,51 @@ | ||||
| package me.ash.reader.data.model.article | ||||
| 
 | ||||
| import androidx.paging.PagingData | ||||
| import androidx.paging.insertSeparators | ||||
| import androidx.paging.map | ||||
| import me.ash.reader.data.repository.StringsRepository | ||||
| 
 | ||||
| /** | ||||
|  * Provide paginated and inserted separator data types for article list view. | ||||
|  * | ||||
|  * @see me.ash.reader.ui.page.home.flow.ArticleList | ||||
|  */ | ||||
| sealed class ArticleFlowItem { | ||||
| 
 | ||||
|     /** | ||||
|      * The [Article] item. | ||||
|      * | ||||
|      * @see me.ash.reader.ui.page.home.flow.ArticleItem | ||||
|      */ | ||||
|     class Article(val articleWithFeed: ArticleWithFeed) : ArticleFlowItem() | ||||
| 
 | ||||
|     /** | ||||
|      * The feed publication date separator between [Article] items. | ||||
|      * | ||||
|      * @see me.ash.reader.ui.page.home.flow.StickyHeader | ||||
|      */ | ||||
|     class Date(val date: String, val showSpacer: Boolean) : ArticleFlowItem() | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Mapping [ArticleWithFeed] list to [ArticleFlowItem] list. | ||||
|  */ | ||||
| fun PagingData<ArticleWithFeed>.mapPagingFlowItem(stringsRepository: StringsRepository): PagingData<ArticleFlowItem> = | ||||
|     map { | ||||
|         ArticleFlowItem.Article(it.apply { | ||||
|             article.dateString = stringsRepository.formatAsString( | ||||
|                 date = article.date, | ||||
|                 onlyHourMinute = true | ||||
|             ) | ||||
|         }) | ||||
|     }.insertSeparators { before, after -> | ||||
|         val beforeDate = | ||||
|             stringsRepository.formatAsString(before?.articleWithFeed?.article?.date) | ||||
|         val afterDate = | ||||
|             stringsRepository.formatAsString(after?.articleWithFeed?.article?.date) | ||||
|         if (beforeDate != afterDate) { | ||||
|             afterDate?.let { ArticleFlowItem.Date(it, beforeDate != null) } | ||||
|         } else { | ||||
|             null | ||||
|         } | ||||
|     } | ||||
| @ -1,8 +1,12 @@ | ||||
| package me.ash.reader.data.entity | ||||
| package me.ash.reader.data.model.article | ||||
| 
 | ||||
| import androidx.room.Embedded | ||||
| import androidx.room.Relation | ||||
| import me.ash.reader.data.model.feed.Feed | ||||
| 
 | ||||
| /** | ||||
|  * An [article] contains a [feed]. | ||||
|  */ | ||||
| data class ArticleWithFeed( | ||||
|     @Embedded | ||||
|     var article: Article, | ||||
| @ -1,7 +1,11 @@ | ||||
| package me.ash.reader.data.entity | ||||
| package me.ash.reader.data.model.feed | ||||
| 
 | ||||
| import androidx.room.* | ||||
| import me.ash.reader.data.model.group.Group | ||||
| 
 | ||||
| /** | ||||
|  * TODO: Add class description | ||||
|  */ | ||||
| @Entity( | ||||
|     tableName = "feed", | ||||
|     foreignKeys = [ForeignKey( | ||||
| @ -30,6 +34,7 @@ data class Feed( | ||||
|     @ColumnInfo(defaultValue = "false") | ||||
|     var isFullContent: Boolean = false, | ||||
| ) { | ||||
| 
 | ||||
|     @Ignore | ||||
|     var important: Int? = 0 | ||||
| 
 | ||||
| @ -1,11 +1,15 @@ | ||||
| package me.ash.reader.data.entity | ||||
| package me.ash.reader.data.model.feed | ||||
| 
 | ||||
| import androidx.room.Embedded | ||||
| import androidx.room.Relation | ||||
| import me.ash.reader.data.model.article.Article | ||||
| 
 | ||||
| /** | ||||
|  * A [feed] contains many [articles]. | ||||
|  */ | ||||
| data class FeedWithArticle( | ||||
|     @Embedded | ||||
|     var feed: Feed, | ||||
|     @Relation(parentColumn = "id", entityColumn = "feedId") | ||||
|     var articles: List<Article> | ||||
|     var articles: List<Article>, | ||||
| ) | ||||
| @ -1,11 +1,15 @@ | ||||
| package me.ash.reader.data.entity | ||||
| package me.ash.reader.data.model.feed | ||||
| 
 | ||||
| import androidx.room.Embedded | ||||
| import androidx.room.Relation | ||||
| import me.ash.reader.data.model.group.Group | ||||
| 
 | ||||
| /** | ||||
|  * A [feed] contains a [group]. | ||||
|  */ | ||||
| data class FeedWithGroup( | ||||
|     @Embedded | ||||
|     var feed: Feed, | ||||
|     @Relation(parentColumn = "groupId", entityColumn = "id") | ||||
|     var group: Group | ||||
|     var group: Group, | ||||
| ) | ||||
| @ -0,0 +1,15 @@ | ||||
| package me.ash.reader.data.model.feed | ||||
| 
 | ||||
| /** | ||||
|  * Counting the [important] number of articles in feeds and groups is generally | ||||
|  * used in three situations. | ||||
|  * | ||||
|  * - Unread: Articles that have not been read yet | ||||
|  * - Starred: Articles that have been marked as starred | ||||
|  * - All: All articles | ||||
|  */ | ||||
| data class ImportantNum( | ||||
|     val important: Int, | ||||
|     val feedId: String, | ||||
|     val groupId: String, | ||||
| ) | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.model | ||||
| package me.ash.reader.data.model.general | ||||
| 
 | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.FiberManualRecord | ||||
| @ -13,17 +13,46 @@ import androidx.compose.ui.graphics.vector.ImageVector | ||||
| import androidx.compose.ui.res.pluralStringResource | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import me.ash.reader.R | ||||
| import me.ash.reader.data.model.general.Filter.Companion.All | ||||
| import me.ash.reader.data.model.general.Filter.Companion.Starred | ||||
| import me.ash.reader.data.model.general.Filter.Companion.Unread | ||||
| 
 | ||||
| class Filter( | ||||
| /** | ||||
|  * Indicates filter conditions. | ||||
|  * | ||||
|  * - [All]: all items | ||||
|  * - [Unread]: unread items | ||||
|  * - [Starred]: starred items | ||||
|  */ | ||||
| class Filter private constructor( | ||||
|     val index: Int, | ||||
|     val iconOutline: ImageVector, | ||||
|     val iconFilled: ImageVector, | ||||
| ) { | ||||
| 
 | ||||
|     fun isStarred(): Boolean = this == Starred | ||||
|     fun isUnread(): Boolean = this == Unread | ||||
|     fun isAll(): Boolean = this == All | ||||
| 
 | ||||
|     @Stable | ||||
|     @Composable | ||||
|     fun toName(): String = when (this) { | ||||
|         Unread -> stringResource(R.string.unread) | ||||
|         Starred -> stringResource(R.string.starred) | ||||
|         else -> stringResource(R.string.all) | ||||
|     } | ||||
| 
 | ||||
|     @OptIn(ExperimentalComposeUiApi::class) | ||||
|     @Stable | ||||
|     @Composable | ||||
|     fun toDesc(important: Int): String = when (this) { | ||||
|         Starred -> pluralStringResource(R.plurals.starred_desc, important, important) | ||||
|         Unread -> pluralStringResource(R.plurals.unread_desc, important, important) | ||||
|         else -> pluralStringResource(R.plurals.all_desc, important, important) | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val Starred = Filter( | ||||
|             index = 0, | ||||
|             iconOutline = Icons.Rounded.StarOutline, | ||||
| @ -42,20 +71,3 @@ class Filter( | ||||
|         val values = listOf(Starred, Unread, All) | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| @Stable | ||||
| @Composable | ||||
| fun Filter.getName(): String = when (this) { | ||||
|     Filter.Unread -> stringResource(R.string.unread) | ||||
|     Filter.Starred -> stringResource(R.string.starred) | ||||
|     else -> stringResource(R.string.all) | ||||
| } | ||||
| 
 | ||||
| @OptIn(ExperimentalComposeUiApi::class) | ||||
| @Stable | ||||
| @Composable | ||||
| fun Filter.getDesc(important: Int): String = when (this) { | ||||
|     Filter.Starred -> pluralStringResource(R.plurals.starred_desc, important, important) | ||||
|     Filter.Unread -> pluralStringResource(R.plurals.unread_desc, important, important) | ||||
|     else -> pluralStringResource(R.plurals.all_desc, important, important) | ||||
| } | ||||
| @ -0,0 +1,33 @@ | ||||
| package me.ash.reader.data.model.general | ||||
| 
 | ||||
| import me.ash.reader.data.model.general.MarkAsReadConditions.* | ||||
| import java.util.* | ||||
| 
 | ||||
| /** | ||||
|  * Mark as read conditions. | ||||
|  * | ||||
|  * - [SevenDays]: Mark as read if more than 7 days old | ||||
|  * - [ThreeDays]: Mark as read if more than 3 days old | ||||
|  * - [OneDay]: Mark as read if more than 1 day old | ||||
|  * - [All]: Mark all as read | ||||
|  */ | ||||
| enum class MarkAsReadConditions { | ||||
|     SevenDays, | ||||
|     ThreeDays, | ||||
|     OneDay, | ||||
|     All, | ||||
|     ; | ||||
| 
 | ||||
|     fun toDate(): Date? = when (this) { | ||||
|         All -> null | ||||
|         else -> Calendar.getInstance().apply { | ||||
|             time = Date() | ||||
|             add(Calendar.DAY_OF_MONTH, when (this@MarkAsReadConditions) { | ||||
|                 SevenDays -> -7 | ||||
|                 ThreeDays -> -3 | ||||
|                 OneDay -> -1 | ||||
|                 else -> throw IllegalArgumentException("Unknown condition: $this") | ||||
|             }) | ||||
|         }.time | ||||
|     } | ||||
| } | ||||
| @ -0,0 +1,51 @@ | ||||
| package me.ash.reader.data.model.general | ||||
| 
 | ||||
| /** | ||||
|  * Application version number, consisting of three fields. | ||||
|  * | ||||
|  * - [major]: The major version number, such as 1 | ||||
|  * - [minor]: The major version number, such as 2 | ||||
|  * - [point]: The major version number, such as 3 (if converted to a string, | ||||
|  * the value is: "1.2.3") | ||||
|  */ | ||||
| class Version(numbers: List<String>) { | ||||
| 
 | ||||
|     private var major: Int = 0 | ||||
|     private var minor: Int = 0 | ||||
|     private var point: Int = 0 | ||||
| 
 | ||||
|     init { | ||||
|         major = numbers.getOrNull(0)?.toIntOrNull() ?: 0 | ||||
|         minor = numbers.getOrNull(1)?.toIntOrNull() ?: 0 | ||||
|         point = numbers.getOrNull(2)?.toIntOrNull() ?: 0 | ||||
|     } | ||||
| 
 | ||||
|     constructor() : this(listOf()) | ||||
|     constructor(string: String?) : this(string?.split(".") ?: listOf()) | ||||
| 
 | ||||
|     override fun toString() = "$major.$minor.$point" | ||||
| 
 | ||||
|     /** | ||||
|      * Use [major], [minor], [point] for comparison. | ||||
|      * | ||||
|      * 1. [major] <=> [other.major] | ||||
|      * 2. [minor] <=> [other.minor] | ||||
|      * 3. [point] <=> [other.point] | ||||
|      */ | ||||
|     operator fun compareTo(other: Version): Int = when { | ||||
|         major > other.major -> 1 | ||||
|         major < other.major -> -1 | ||||
|         minor > other.minor -> 1 | ||||
|         minor < other.minor -> -1 | ||||
|         point > other.point -> 1 | ||||
|         point < other.point -> -1 | ||||
|         else -> 0 | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Returns whether this version is larger [current] version and [skip] version. | ||||
|      */ | ||||
|     fun whetherNeedUpdate(current: Version, skip: Version): Boolean = this > current && this > skip | ||||
| } | ||||
| 
 | ||||
| fun String?.toVersion(): Version = Version(this) | ||||
| @ -1,10 +1,13 @@ | ||||
| package me.ash.reader.data.entity | ||||
| package me.ash.reader.data.model.group | ||||
| 
 | ||||
| import androidx.room.ColumnInfo | ||||
| import androidx.room.Entity | ||||
| import androidx.room.Ignore | ||||
| import androidx.room.PrimaryKey | ||||
| 
 | ||||
| /** | ||||
|  * TODO: Add class description | ||||
|  */ | ||||
| @Entity(tableName = "group") | ||||
| data class Group( | ||||
|     @PrimaryKey | ||||
| @ -14,6 +17,7 @@ data class Group( | ||||
|     @ColumnInfo(index = true) | ||||
|     var accountId: Int, | ||||
| ) { | ||||
| 
 | ||||
|     @Ignore | ||||
|     var important: Int? = 0 | ||||
| } | ||||
| @ -1,11 +1,15 @@ | ||||
| package me.ash.reader.data.entity | ||||
| package me.ash.reader.data.model.group | ||||
| 
 | ||||
| import androidx.room.Embedded | ||||
| import androidx.room.Relation | ||||
| import me.ash.reader.data.model.feed.Feed | ||||
| 
 | ||||
| /** | ||||
|  * A [group] contains many [feeds]. | ||||
|  */ | ||||
| data class GroupWithFeed( | ||||
|     @Embedded | ||||
|     var group: Group, | ||||
|     @Relation(parentColumn = "id", entityColumn = "groupId") | ||||
|     var feeds: MutableList<Feed> | ||||
|     var feeds: MutableList<Feed>, | ||||
| ) | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class AmoledDarkThemePreference(val value: Boolean) : Preference() { | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = OFF | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -9,6 +9,7 @@ import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object CustomPrimaryColorPreference { | ||||
| 
 | ||||
|     const val default = "" | ||||
| 
 | ||||
|     fun put(context: Context, scope: CoroutineScope, value: String) { | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.compose.foundation.isSystemInDarkTheme | ||||
| @ -26,7 +26,7 @@ sealed class DarkThemePreference(val value: Int) : Preference() { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             UseDeviceTheme -> context.getString(R.string.use_device_theme) | ||||
|             ON -> context.getString(R.string.on) | ||||
| @ -42,6 +42,7 @@ sealed class DarkThemePreference(val value: Int) : Preference() { | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = UseDeviceTheme | ||||
|         val values = listOf(UseDeviceTheme, ON, OFF) | ||||
| 
 | ||||
| @ -63,6 +64,7 @@ operator fun DarkThemePreference.not(): DarkThemePreference = | ||||
|         } else { | ||||
|             DarkThemePreference.ON | ||||
|         } | ||||
| 
 | ||||
|         DarkThemePreference.ON -> DarkThemePreference.OFF | ||||
|         DarkThemePreference.OFF -> DarkThemePreference.ON | ||||
|     } | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class FeedsFilterBarFilledPreference(val value: Boolean) : Preference() { | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = OFF | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -9,6 +9,7 @@ import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object FeedsFilterBarPaddingPreference { | ||||
| 
 | ||||
|     const val default = 60 | ||||
| 
 | ||||
|     fun put(context: Context, scope: CoroutineScope, value: Int) { | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -23,7 +23,7 @@ sealed class FeedsFilterBarStylePreference(val value: Int) : Preference() { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             Icon -> context.getString(R.string.icons) | ||||
|             IconLabel -> context.getString(R.string.icons_and_labels) | ||||
| @ -31,6 +31,7 @@ sealed class FeedsFilterBarStylePreference(val value: Int) : Preference() { | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = Icon | ||||
|         val values = listOf(Icon, IconLabel, IconLabelOnlySelected) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -26,7 +26,7 @@ sealed class FeedsFilterBarTonalElevationPreference(val value: Int) : Preference | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             Level0 -> "Level 0 (${ElevationTokens.Level0}dp)" | ||||
|             Level1 -> "Level 1 (${ElevationTokens.Level1}dp)" | ||||
| @ -37,6 +37,7 @@ sealed class FeedsFilterBarTonalElevationPreference(val value: Int) : Preference | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = Level0 | ||||
|         val values = listOf(Level0, Level1, Level2, Level3, Level4, Level5) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class FeedsGroupListExpandPreference(val value: Boolean) : Preference() { | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = ON | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -26,7 +26,7 @@ sealed class FeedsGroupListTonalElevationPreference(val value: Int) : Preference | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             Level0 -> "Level 0 (${ElevationTokens.Level0}dp)" | ||||
|             Level1 -> "Level 1 (${ElevationTokens.Level1}dp)" | ||||
| @ -37,6 +37,7 @@ sealed class FeedsGroupListTonalElevationPreference(val value: Int) : Preference | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = Level0 | ||||
|         val values = listOf(Level0, Level1, Level2, Level3, Level4, Level5) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -26,7 +26,7 @@ sealed class FeedsTopBarTonalElevationPreference(val value: Int) : Preference() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             Level0 -> "Level 0 (${ElevationTokens.Level0}dp)" | ||||
|             Level1 -> "Level 1 (${ElevationTokens.Level1}dp)" | ||||
| @ -37,6 +37,7 @@ sealed class FeedsTopBarTonalElevationPreference(val value: Int) : Preference() | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = Level0 | ||||
|         val values = listOf(Level0, Level1, Level2, Level3, Level4, Level5) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class FlowArticleListDateStickyHeaderPreference(val value: Boolean) : Pre | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = ON | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class FlowArticleListDescPreference(val value: Boolean) : Preference() { | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = ON | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class FlowArticleListFeedIconPreference(val value: Boolean) : Preference( | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = ON | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class FlowArticleListFeedNamePreference(val value: Boolean) : Preference( | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = ON | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class FlowArticleListImagePreference(val value: Boolean) : Preference() { | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = ON | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class FlowArticleListTimePreference(val value: Boolean) : Preference() { | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = ON | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -26,7 +26,7 @@ sealed class FlowArticleListTonalElevationPreference(val value: Int) : Preferenc | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             Level0 -> "Level 0 (${ElevationTokens.Level0}dp)" | ||||
|             Level1 -> "Level 1 (${ElevationTokens.Level1}dp)" | ||||
| @ -37,6 +37,7 @@ sealed class FlowArticleListTonalElevationPreference(val value: Int) : Preferenc | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = Level0 | ||||
|         val values = listOf(Level0, Level1, Level2, Level3, Level4, Level5) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,6 +22,7 @@ sealed class FlowFilterBarFilledPreference(val value: Boolean) : Preference() { | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = OFF | ||||
|         val values = listOf(ON, OFF) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -9,6 +9,7 @@ import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object FlowFilterBarPaddingPreference { | ||||
| 
 | ||||
|     const val default = 60 | ||||
| 
 | ||||
|     fun put(context: Context, scope: CoroutineScope, value: Int) { | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -23,7 +23,7 @@ sealed class FlowFilterBarStylePreference(val value: Int) : Preference() { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             Icon -> context.getString(R.string.icons) | ||||
|             IconLabel -> context.getString(R.string.icons_and_labels) | ||||
| @ -31,6 +31,7 @@ sealed class FlowFilterBarStylePreference(val value: Int) : Preference() { | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = Icon | ||||
|         val values = listOf(Icon, IconLabel, IconLabelOnlySelected) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -26,7 +26,7 @@ sealed class FlowFilterBarTonalElevationPreference(val value: Int) : Preference( | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             Level0 -> "Level 0 (${ElevationTokens.Level0}dp)" | ||||
|             Level1 -> "Level 1 (${ElevationTokens.Level1}dp)" | ||||
| @ -37,6 +37,7 @@ sealed class FlowFilterBarTonalElevationPreference(val value: Int) : Preference( | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = Level0 | ||||
|         val values = listOf(Level0, Level1, Level2, Level3, Level4, Level5) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -26,7 +26,7 @@ sealed class FlowTopBarTonalElevationPreference(val value: Int) : Preference() { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             Level0 -> "Level 0 (${ElevationTokens.Level0}dp)" | ||||
|             Level1 -> "Level 1 (${ElevationTokens.Level1}dp)" | ||||
| @ -37,6 +37,7 @@ sealed class FlowTopBarTonalElevationPreference(val value: Int) : Preference() { | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = Level0 | ||||
|         val values = listOf(Level0, Level1, Level2, Level3, Level4, Level5) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -23,7 +23,7 @@ sealed class InitialFilterPreference(val value: Int) : Preference() { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             Starred -> context.getString(R.string.starred) | ||||
|             Unread -> context.getString(R.string.unread) | ||||
| @ -31,6 +31,7 @@ sealed class InitialFilterPreference(val value: Int) : Preference() { | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = All | ||||
|         val values = listOf(Starred, Unread, All) | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -22,13 +22,14 @@ sealed class InitialPagePreference(val value: Int) : Preference() { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             FeedsPage -> context.getString(R.string.feeds_page) | ||||
|             FlowPage -> context.getString(R.string.flow_page) | ||||
|         } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = FeedsPage | ||||
|         val values = listOf(FeedsPage, FlowPage) | ||||
| 
 | ||||
| @ -1,8 +1,7 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import android.os.LocaleList | ||||
| import android.util.Log | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.launch | ||||
| @ -19,6 +18,7 @@ sealed class LanguagesPreference(val value: Int) : Preference() { | ||||
|     object German : LanguagesPreference(3) | ||||
|     object French : LanguagesPreference(4) | ||||
|     object Czech : LanguagesPreference(5) | ||||
|     object Italian : LanguagesPreference(6) | ||||
| 
 | ||||
|     override fun put(context: Context, scope: CoroutineScope) { | ||||
|         scope.launch { | ||||
| @ -30,7 +30,7 @@ sealed class LanguagesPreference(val value: Int) : Preference() { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun getDesc(context: Context): String = | ||||
|     fun toDesc(context: Context): String = | ||||
|         when (this) { | ||||
|             UseDeviceLanguages -> context.getString(R.string.use_device_languages) | ||||
|             English -> context.getString(R.string.english) | ||||
| @ -38,6 +38,7 @@ sealed class LanguagesPreference(val value: Int) : Preference() { | ||||
|             German -> context.getString(R.string.german) | ||||
|             French -> context.getString(R.string.french) | ||||
|             Czech -> context.getString(R.string.czech) | ||||
|             Italian -> context.getString(R.string.italian) | ||||
|         } | ||||
| 
 | ||||
|     fun getLocale(): Locale = | ||||
| @ -48,13 +49,11 @@ sealed class LanguagesPreference(val value: Int) : Preference() { | ||||
|             German -> Locale("de", "DE") | ||||
|             French -> Locale("fr", "FR") | ||||
|             Czech -> Locale("cs", "CZ") | ||||
|             Italian -> Locale("it", "IT") | ||||
|         } | ||||
| 
 | ||||
|     fun setLocale(context: Context) { | ||||
|         val locale = getLocale() | ||||
| 
 | ||||
|         Log.i("Rlog", "setLocale: $locale, ${LocaleList.getDefault().get(0)}") | ||||
| 
 | ||||
|         val resources = context.resources | ||||
|         val metrics = resources.displayMetrics | ||||
|         val configuration = resources.configuration | ||||
| @ -73,8 +72,9 @@ sealed class LanguagesPreference(val value: Int) : Preference() { | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         val default = UseDeviceLanguages | ||||
|         val values = listOf(UseDeviceLanguages, English, ChineseSimplified, German, French, Czech) | ||||
|         val values = listOf(UseDeviceLanguages, English, ChineseSimplified, German, French, Czech, Italian) | ||||
| 
 | ||||
|         fun fromPreferences(preferences: Preferences): LanguagesPreference = | ||||
|             when (preferences[DataStoreKeys.Languages.key]) { | ||||
| @ -84,6 +84,7 @@ sealed class LanguagesPreference(val value: Int) : Preference() { | ||||
|                 3 -> German | ||||
|                 4 -> French | ||||
|                 5 -> Czech | ||||
|                 6 -> Italian | ||||
|                 else -> default | ||||
|             } | ||||
| 
 | ||||
| @ -95,6 +96,7 @@ sealed class LanguagesPreference(val value: Int) : Preference() { | ||||
|                 3 -> German | ||||
|                 4 -> French | ||||
|                 5 -> Czech | ||||
|                 6 -> Italian | ||||
|                 else -> default | ||||
|             } | ||||
|     } | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -10,6 +10,7 @@ import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object NewVersionDownloadUrlPreference { | ||||
| 
 | ||||
|     const val default = "" | ||||
| 
 | ||||
|     fun put(context: Context, scope: CoroutineScope, value: String) { | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -10,6 +10,7 @@ import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object NewVersionLogPreference { | ||||
| 
 | ||||
|     const val default = "" | ||||
| 
 | ||||
|     fun put(context: Context, scope: CoroutineScope, value: String) { | ||||
| @ -1,17 +1,18 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.launch | ||||
| import me.ash.reader.data.model.Version | ||||
| import me.ash.reader.data.model.toVersion | ||||
| import me.ash.reader.data.model.general.Version | ||||
| import me.ash.reader.data.model.general.toVersion | ||||
| import me.ash.reader.ui.ext.DataStoreKeys | ||||
| import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object NewVersionNumberPreference { | ||||
| 
 | ||||
|     val default = Version() | ||||
| 
 | ||||
|     fun put(context: Context, scope: CoroutineScope, value: String) { | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -10,6 +10,7 @@ import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object NewVersionPublishDatePreference { | ||||
| 
 | ||||
|     const val default = "" | ||||
| 
 | ||||
|     fun put(context: Context, scope: CoroutineScope, value: String) { | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -10,6 +10,7 @@ import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object NewVersionSizePreference { | ||||
| 
 | ||||
|     const val default = "" | ||||
| 
 | ||||
|     fun Int.formatSize(): String = | ||||
| @ -1,10 +1,11 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| 
 | ||||
| sealed class Preference { | ||||
| 
 | ||||
|     abstract fun put(context: Context, scope: CoroutineScope) | ||||
| } | ||||
| 
 | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.util.Log | ||||
| import androidx.compose.runtime.Composable | ||||
| @ -7,7 +7,7 @@ import androidx.compose.runtime.compositionLocalOf | ||||
| import androidx.compose.runtime.remember | ||||
| import androidx.compose.ui.platform.LocalContext | ||||
| import kotlinx.coroutines.flow.map | ||||
| import me.ash.reader.data.model.Version | ||||
| import me.ash.reader.data.model.general.Version | ||||
| import me.ash.reader.ui.ext.collectAsStateValue | ||||
| import me.ash.reader.ui.ext.dataStore | ||||
| 
 | ||||
| @ -1,17 +1,18 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| import kotlinx.coroutines.launch | ||||
| import me.ash.reader.data.model.Version | ||||
| import me.ash.reader.data.model.toVersion | ||||
| import me.ash.reader.data.model.general.Version | ||||
| import me.ash.reader.data.model.general.toVersion | ||||
| import me.ash.reader.ui.ext.DataStoreKeys | ||||
| import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object SkipVersionNumberPreference { | ||||
| 
 | ||||
|     val default = Version() | ||||
| 
 | ||||
|     fun put(context: Context, scope: CoroutineScope, value: String) { | ||||
| @ -1,4 +1,4 @@ | ||||
| package me.ash.reader.data.preference | ||||
| package me.ash.reader.data.model.preference | ||||
| 
 | ||||
| import android.content.Context | ||||
| import androidx.datastore.preferences.core.Preferences | ||||
| @ -10,6 +10,7 @@ import me.ash.reader.ui.ext.dataStore | ||||
| import me.ash.reader.ui.ext.put | ||||
| 
 | ||||
| object ThemeIndexPreference { | ||||
| 
 | ||||
|     const val default = 5 | ||||
| 
 | ||||
|     fun put(context: Context, scope: CoroutineScope, value: Int) { | ||||
| @ -0,0 +1,13 @@ | ||||
| package me.ash.reader.data.module | ||||
| 
 | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import javax.inject.Qualifier | ||||
| 
 | ||||
| /** | ||||
|  * Provides [CoroutineScope] for the application. | ||||
|  * | ||||
|  * @see CoroutineScopeModule.provideCoroutineScope | ||||
|  */ | ||||
| @Retention(AnnotationRetention.RUNTIME) | ||||
| @Qualifier | ||||
| annotation class ApplicationScope | ||||
| @ -7,23 +7,31 @@ import dagger.hilt.components.SingletonComponent | ||||
| import kotlinx.coroutines.CoroutineDispatcher | ||||
| import kotlinx.coroutines.Dispatchers | ||||
| 
 | ||||
| /** | ||||
|  * Provides global coroutine dispatcher. | ||||
|  * | ||||
|  * - [Dispatchers.Main] | ||||
|  * - [Dispatchers.Main.immediate] | ||||
|  * - [Dispatchers.IO] | ||||
|  * - [Dispatchers.Default] | ||||
|  */ | ||||
| @Module | ||||
| @InstallIn(SingletonComponent::class) | ||||
| object CoroutineDispatcherModule { | ||||
| 
 | ||||
|     @Provides | ||||
|     @DispatcherDefault | ||||
|     fun provideDispatcherDefault(): CoroutineDispatcher = Dispatchers.Default | ||||
|     @DefaultDispatcher | ||||
|     fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default | ||||
| 
 | ||||
|     @Provides | ||||
|     @DispatcherIO | ||||
|     fun provideDispatcherIO(): CoroutineDispatcher = Dispatchers.IO | ||||
|     @IODispatcher | ||||
|     fun provideIODispatcher(): CoroutineDispatcher = Dispatchers.IO | ||||
| 
 | ||||
|     @Provides | ||||
|     @DispatcherMain | ||||
|     fun provideDispatcherMain(): CoroutineDispatcher = Dispatchers.Main | ||||
|     @MainDispatcher | ||||
|     fun provideMainDispatcher(): CoroutineDispatcher = Dispatchers.Main | ||||
| 
 | ||||
|     @Provides | ||||
|     @DispatcherMainImmediate | ||||
|     fun provideDispatcherMainImmediate(): CoroutineDispatcher = Dispatchers.Main.immediate | ||||
|     @MainImmediateDispatcher | ||||
|     fun provideMainImmediateDispatcher(): CoroutineDispatcher = Dispatchers.Main.immediate | ||||
| } | ||||
|  | ||||
| @ -2,18 +2,30 @@ package me.ash.reader.data.module | ||||
| 
 | ||||
| import javax.inject.Qualifier | ||||
| 
 | ||||
| /** | ||||
|  * @see CoroutineDispatcherModule.provideDefaultDispatcher | ||||
|  */ | ||||
| @Retention(AnnotationRetention.RUNTIME) | ||||
| @Qualifier | ||||
| annotation class DispatcherDefault | ||||
| annotation class DefaultDispatcher | ||||
| 
 | ||||
| /** | ||||
|  * @see CoroutineDispatcherModule.provideIODispatcher | ||||
|  */ | ||||
| @Retention(AnnotationRetention.RUNTIME) | ||||
| @Qualifier | ||||
| annotation class DispatcherIO | ||||
| annotation class IODispatcher | ||||
| 
 | ||||
| /** | ||||
|  * @see CoroutineDispatcherModule.provideMainDispatcher | ||||
|  */ | ||||
| @Retention(AnnotationRetention.RUNTIME) | ||||
| @Qualifier | ||||
| annotation class DispatcherMain | ||||
| annotation class MainDispatcher | ||||
| 
 | ||||
| /** | ||||
|  * @see CoroutineDispatcherModule.provideMainImmediateDispatcher | ||||
|  */ | ||||
| @Retention(AnnotationRetention.BINARY) | ||||
| @Qualifier | ||||
| annotation class DispatcherMainImmediate | ||||
| annotation class MainImmediateDispatcher | ||||
|  | ||||
| @ -7,13 +7,12 @@ import dagger.hilt.components.SingletonComponent | ||||
| import kotlinx.coroutines.CoroutineDispatcher | ||||
| import kotlinx.coroutines.CoroutineScope | ||||
| import kotlinx.coroutines.SupervisorJob | ||||
| import javax.inject.Qualifier | ||||
| import javax.inject.Singleton | ||||
| 
 | ||||
| @Retention(AnnotationRetention.RUNTIME) | ||||
| @Qualifier | ||||
| annotation class ApplicationScope | ||||
| 
 | ||||
| /** | ||||
|  * [CoroutineScope] for the application consisting of [SupervisorJob] | ||||
|  * and [DefaultDispatcher] context. | ||||
|  */ | ||||
| @Module | ||||
| @InstallIn(SingletonComponent::class) | ||||
| object CoroutineScopeModule { | ||||
| @ -22,6 +21,6 @@ object CoroutineScopeModule { | ||||
|     @Singleton | ||||
|     @ApplicationScope | ||||
|     fun provideCoroutineScope( | ||||
|         @DispatcherDefault dispatcherDefault: CoroutineDispatcher | ||||
|     ): CoroutineScope = CoroutineScope(SupervisorJob() + dispatcherDefault) | ||||
|         @DefaultDispatcher defaultDispatcher: CoroutineDispatcher, | ||||
|     ): CoroutineScope = CoroutineScope(SupervisorJob() + defaultDispatcher) | ||||
| } | ||||
| @ -13,29 +13,37 @@ import me.ash.reader.data.dao.GroupDao | ||||
| import me.ash.reader.data.source.RYDatabase | ||||
| import javax.inject.Singleton | ||||
| 
 | ||||
| /** | ||||
|  * Provides Data Access Objects for database. | ||||
|  * | ||||
|  * - [ArticleDao] | ||||
|  * - [FeedDao] | ||||
|  * - [GroupDao] | ||||
|  * - [AccountDao] | ||||
|  */ | ||||
| @Module | ||||
| @InstallIn(SingletonComponent::class) | ||||
| object DatabaseModule { | ||||
| 
 | ||||
|     @Provides | ||||
|     @Singleton | ||||
|     fun provideArticleDao(RYDatabase: RYDatabase): ArticleDao = | ||||
|         RYDatabase.articleDao() | ||||
|     fun provideArticleDao(ryDatabase: RYDatabase): ArticleDao = | ||||
|         ryDatabase.articleDao() | ||||
| 
 | ||||
|     @Provides | ||||
|     @Singleton | ||||
|     fun provideFeedDao(RYDatabase: RYDatabase): FeedDao = | ||||
|         RYDatabase.feedDao() | ||||
|     fun provideFeedDao(ryDatabase: RYDatabase): FeedDao = | ||||
|         ryDatabase.feedDao() | ||||
| 
 | ||||
|     @Provides | ||||
|     @Singleton | ||||
|     fun provideGroupDao(RYDatabase: RYDatabase): GroupDao = | ||||
|         RYDatabase.groupDao() | ||||
|     fun provideGroupDao(ryDatabase: RYDatabase): GroupDao = | ||||
|         ryDatabase.groupDao() | ||||
| 
 | ||||
|     @Provides | ||||
|     @Singleton | ||||
|     fun provideAccountDao(RYDatabase: RYDatabase): AccountDao = | ||||
|         RYDatabase.accountDao() | ||||
|     fun provideAccountDao(ryDatabase: RYDatabase): AccountDao = | ||||
|         ryDatabase.accountDao() | ||||
| 
 | ||||
|     @Provides | ||||
|     @Singleton | ||||
|  | ||||
| @ -18,6 +18,9 @@ import kotlinx.coroutines.Dispatchers | ||||
| import okhttp3.OkHttpClient | ||||
| import javax.inject.Singleton | ||||
| 
 | ||||
| /** | ||||
|  * Provides singleton [ImageLoader] for Coil. | ||||
|  */ | ||||
| @Module | ||||
| @InstallIn(SingletonComponent::class) | ||||
| object ImageLoaderModule { | ||||
| @ -29,10 +32,14 @@ object ImageLoaderModule { | ||||
|         okHttpClient: OkHttpClient, | ||||
|     ): ImageLoader { | ||||
|         return ImageLoader.Builder(context) | ||||
|             // Shared OKHttpClient instance. | ||||
|             .okHttpClient(okHttpClient) | ||||
|             .dispatcher(Dispatchers.Default) // This slightly improves scrolling performance | ||||
|             .components{ | ||||
|             // This slightly improves scrolling performance | ||||
|             .dispatcher(Dispatchers.Default) | ||||
|             .components { | ||||
|                 // Support SVG decoding | ||||
|                 add(SvgDecoder.Factory()) | ||||
|                 // Support GIF decoding | ||||
|                 add( | ||||
|                     if (SDK_INT >= Build.VERSION_CODES.P) { | ||||
|                         ImageDecoderDecoder.Factory() | ||||
| @ -41,12 +48,14 @@ object ImageLoaderModule { | ||||
|                     } | ||||
|                 ) | ||||
|             } | ||||
|             // Enable disk cache | ||||
|             .diskCache( | ||||
|                 DiskCache.Builder() | ||||
|                     .directory(context.cacheDir.resolve("images")) | ||||
|                     .maxSizePercent(0.02) | ||||
|                     .build() | ||||
|             ) | ||||
|             // Enable memory cache | ||||
|             .memoryCache( | ||||
|                 MemoryCache.Builder(context) | ||||
|                     .maxSizePercent(0.25) | ||||
|  | ||||
| @ -42,13 +42,17 @@ import javax.net.ssl.SSLContext | ||||
| import javax.net.ssl.TrustManager | ||||
| import javax.net.ssl.X509TrustManager | ||||
| 
 | ||||
| /** | ||||
|  * Provides singleton [OkHttpClient] for the application. | ||||
|  */ | ||||
| @Module | ||||
| @InstallIn(SingletonComponent::class) | ||||
| object OkHttpClientModule { | ||||
| 
 | ||||
|     @Provides | ||||
|     @Singleton | ||||
|     fun provideOkHttpClient( | ||||
|         @ApplicationContext context: Context | ||||
|         @ApplicationContext context: Context, | ||||
|     ): OkHttpClient = cachingHttpClient( | ||||
|         cacheDirectory = context.cacheDir.resolve("http") | ||||
|     ).newBuilder() | ||||
| @ -61,7 +65,7 @@ fun cachingHttpClient( | ||||
|     cacheSize: Long = 10L * 1024L * 1024L, | ||||
|     trustAllCerts: Boolean = true, | ||||
|     connectTimeoutSecs: Long = 30L, | ||||
|     readTimeoutSecs: Long = 30L | ||||
|     readTimeoutSecs: Long = 30L, | ||||
| ): OkHttpClient { | ||||
|     val builder: OkHttpClient.Builder = OkHttpClient.Builder() | ||||
| 
 | ||||
| @ -107,6 +111,7 @@ fun OkHttpClient.Builder.trustAllCerts() { | ||||
| } | ||||
| 
 | ||||
| object UserAgentInterceptor : Interceptor { | ||||
| 
 | ||||
|     override fun intercept(chain: Interceptor.Chain): Response { | ||||
|         return chain.proceed( | ||||
|             chain.request() | ||||
|  | ||||
| @ -4,11 +4,18 @@ import dagger.Module | ||||
| import dagger.Provides | ||||
| import dagger.hilt.InstallIn | ||||
| import dagger.hilt.components.SingletonComponent | ||||
| import me.ash.reader.data.source.RYNetworkDataSource | ||||
| 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 | ||||
| 
 | ||||
| /** | ||||
|  * 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) | ||||
| object RetrofitModule { | ||||
|  | ||||
| @ -9,6 +9,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import dagger.hilt.components.SingletonComponent | ||||
| import javax.inject.Singleton | ||||
| 
 | ||||
| /** | ||||
|  * Provides singleton [WorkManager] for the application. | ||||
|  */ | ||||
| @Module | ||||
| @InstallIn(SingletonComponent::class) | ||||
| object WorkerModule { | ||||
|  | ||||
| @ -15,7 +15,11 @@ 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.entity.* | ||||
| 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.group.Group | ||||
| import me.ash.reader.data.model.group.GroupWithFeed | ||||
| import me.ash.reader.ui.ext.currentAccountId | ||||
| import java.util.* | ||||
| 
 | ||||
| @ -29,6 +33,7 @@ abstract class AbstractRssRepository constructor( | ||||
|     private val dispatcherIO: CoroutineDispatcher, | ||||
|     private val dispatcherDefault: CoroutineDispatcher, | ||||
| ) { | ||||
| 
 | ||||
|     abstract suspend fun updateArticleInfo(article: Article) | ||||
| 
 | ||||
|     abstract suspend fun subscribe(feed: Feed, articles: List<Article>) | ||||
| @ -53,19 +58,17 @@ abstract class AbstractRssRepository constructor( | ||||
|         ) | ||||
|     } | ||||
| 
 | ||||
|     fun pullGroups(): Flow<MutableList<Group>> { | ||||
|         return groupDao.queryAllGroup(context.currentAccountId).flowOn(dispatcherIO) | ||||
|     } | ||||
|     fun pullGroups(): Flow<MutableList<Group>> = | ||||
|         groupDao.queryAllGroup(context.currentAccountId).flowOn(dispatcherIO) | ||||
| 
 | ||||
|     fun pullFeeds(): Flow<MutableList<GroupWithFeed>> { | ||||
|         return groupDao.queryAllGroupWithFeedAsFlow(context.currentAccountId).flowOn(dispatcherIO) | ||||
|     } | ||||
|     fun pullFeeds(): Flow<MutableList<GroupWithFeed>> = | ||||
|         groupDao.queryAllGroupWithFeedAsFlow(context.currentAccountId).flowOn(dispatcherIO) | ||||
| 
 | ||||
|     fun pullArticles( | ||||
|         groupId: String? = null, | ||||
|         feedId: String? = null, | ||||
|         isStarred: Boolean = false, | ||||
|         isUnread: Boolean = false, | ||||
|         groupId: String?, | ||||
|         feedId: String?, | ||||
|         isStarred: Boolean, | ||||
|         isUnread: Boolean, | ||||
|     ): PagingSource<Int, ArticleWithFeed> { | ||||
|         val accountId = context.currentAccountId | ||||
|         Log.i( | ||||
| @ -74,32 +77,28 @@ abstract class AbstractRssRepository constructor( | ||||
|         ) | ||||
|         return when { | ||||
|             groupId != null -> when { | ||||
|                 isStarred -> articleDao | ||||
|                     .queryArticleWithFeedByGroupIdWhenIsStarred(accountId, groupId, isStarred) | ||||
|                 isUnread -> articleDao | ||||
|                     .queryArticleWithFeedByGroupIdWhenIsUnread(accountId, groupId, isUnread) | ||||
|                 isStarred -> articleDao.queryArticleWithFeedByGroupIdWhenIsStarred(accountId, groupId, true) | ||||
|                 isUnread -> articleDao.queryArticleWithFeedByGroupIdWhenIsUnread(accountId, groupId, true) | ||||
|                 else -> articleDao.queryArticleWithFeedByGroupIdWhenIsAll(accountId, groupId) | ||||
|             } | ||||
| 
 | ||||
|             feedId != null -> when { | ||||
|                 isStarred -> articleDao | ||||
|                     .queryArticleWithFeedByFeedIdWhenIsStarred(accountId, feedId, isStarred) | ||||
|                 isUnread -> articleDao | ||||
|                     .queryArticleWithFeedByFeedIdWhenIsUnread(accountId, feedId, isUnread) | ||||
|                 isStarred -> articleDao.queryArticleWithFeedByFeedIdWhenIsStarred(accountId, feedId, true) | ||||
|                 isUnread -> articleDao.queryArticleWithFeedByFeedIdWhenIsUnread(accountId, feedId, true) | ||||
|                 else -> articleDao.queryArticleWithFeedByFeedIdWhenIsAll(accountId, feedId) | ||||
|             } | ||||
| 
 | ||||
|             else -> when { | ||||
|                 isStarred -> articleDao | ||||
|                     .queryArticleWithFeedWhenIsStarred(accountId, isStarred) | ||||
|                 isUnread -> articleDao | ||||
|                     .queryArticleWithFeedWhenIsUnread(accountId, isUnread) | ||||
|                 isStarred -> articleDao.queryArticleWithFeedWhenIsStarred(accountId, true) | ||||
|                 isUnread -> articleDao.queryArticleWithFeedWhenIsUnread(accountId, true) | ||||
|                 else -> articleDao.queryArticleWithFeedWhenIsAll(accountId) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun pullImportant( | ||||
|         isStarred: Boolean = false, | ||||
|         isUnread: Boolean = false, | ||||
|         isStarred: Boolean, | ||||
|         isUnread: Boolean, | ||||
|     ): Flow<Map<String, Int>> { | ||||
|         val accountId = context.currentAccountId | ||||
|         Log.i( | ||||
| @ -107,42 +106,28 @@ abstract class AbstractRssRepository constructor( | ||||
|             "pullImportant: accountId: ${accountId}, isStarred: ${isStarred}, isUnread: ${isUnread}" | ||||
|         ) | ||||
|         return when { | ||||
|             isStarred -> articleDao | ||||
|                 .queryImportantCountWhenIsStarred(accountId, isStarred) | ||||
|             isUnread -> articleDao | ||||
|                 .queryImportantCountWhenIsUnread(accountId, isUnread) | ||||
|             isStarred -> articleDao.queryImportantCountWhenIsStarred(accountId, true) | ||||
|             isUnread -> articleDao.queryImportantCountWhenIsUnread(accountId, true) | ||||
|             else -> articleDao.queryImportantCountWhenIsAll(accountId) | ||||
|         }.mapLatest { | ||||
|             mapOf( | ||||
|                 // Groups | ||||
|                 *(it.groupBy { it.groupId }.map { | ||||
|                     it.key to it.value.sumOf { it.important } | ||||
|                 }.toTypedArray()), | ||||
|                 *(it.groupBy { it.groupId }.map { it.key to it.value.sumOf { it.important } }.toTypedArray()), | ||||
|                 // Feeds | ||||
|                 *(it.map { | ||||
|                     it.feedId to it.important | ||||
|                 }.toTypedArray()), | ||||
|                 *(it.map { it.feedId to it.important }.toTypedArray()), | ||||
|                 // All summary | ||||
|                 "sum" to it.sumOf { it.important } | ||||
|             ) | ||||
|         }.flowOn(dispatcherDefault) | ||||
|     } | ||||
| 
 | ||||
|     suspend fun findFeedById(id: String): Feed? { | ||||
|         return feedDao.queryById(id) | ||||
|     } | ||||
|     suspend fun findFeedById(id: String): Feed? = feedDao.queryById(id) | ||||
| 
 | ||||
|     suspend fun findGroupById(id: String): Group? { | ||||
|         return groupDao.queryById(id) | ||||
|     } | ||||
|     suspend fun findGroupById(id: String): Group? = groupDao.queryById(id) | ||||
| 
 | ||||
|     suspend fun findArticleById(id: String): ArticleWithFeed? { | ||||
|         return articleDao.queryById(id) | ||||
|     } | ||||
|     suspend fun findArticleById(id: String): ArticleWithFeed? = articleDao.queryById(id) | ||||
| 
 | ||||
|     suspend fun isFeedExist(url: String): Boolean { | ||||
|         return feedDao.queryByLink(context.currentAccountId, url).isNotEmpty() | ||||
|     } | ||||
|     suspend fun isFeedExist(url: String): Boolean = feedDao.queryByLink(context.currentAccountId, url).isNotEmpty() | ||||
| 
 | ||||
|     suspend fun updateGroup(group: Group) { | ||||
|         groupDao.update(group) | ||||
| @ -184,10 +169,10 @@ abstract class AbstractRssRepository constructor( | ||||
| 
 | ||||
|     fun searchArticles( | ||||
|         content: String, | ||||
|         groupId: String? = null, | ||||
|         feedId: String? = null, | ||||
|         isStarred: Boolean = false, | ||||
|         isUnread: Boolean = false, | ||||
|         groupId: String?, | ||||
|         feedId: String?, | ||||
|         isStarred: Boolean, | ||||
|         isUnread: Boolean, | ||||
|     ): PagingSource<Int, ArticleWithFeed> { | ||||
|         val accountId = context.currentAccountId | ||||
|         Log.i( | ||||
| @ -196,22 +181,20 @@ abstract class AbstractRssRepository constructor( | ||||
|         ) | ||||
|         return when { | ||||
|             groupId != null -> when { | ||||
|                 isStarred -> articleDao | ||||
|                     .searchArticleByGroupIdWhenIsStarred(accountId, content, groupId, isStarred) | ||||
|                 isUnread -> articleDao | ||||
|                     .searchArticleByGroupIdWhenIsUnread(accountId, content, groupId, isUnread) | ||||
|                 isStarred -> articleDao.searchArticleByGroupIdWhenIsStarred(accountId, content, groupId, true) | ||||
|                 isUnread -> articleDao.searchArticleByGroupIdWhenIsUnread(accountId, content, groupId, true) | ||||
|                 else -> articleDao.searchArticleByGroupIdWhenAll(accountId, content, groupId) | ||||
|             } | ||||
| 
 | ||||
|             feedId != null -> when { | ||||
|                 isStarred -> articleDao | ||||
|                     .searchArticleByFeedIdWhenIsStarred(accountId, content, feedId, isStarred) | ||||
|                 isUnread -> articleDao | ||||
|                     .searchArticleByFeedIdWhenIsUnread(accountId, content, feedId, isUnread) | ||||
|                 isStarred -> articleDao.searchArticleByFeedIdWhenIsStarred(accountId, content, feedId, true) | ||||
|                 isUnread -> articleDao.searchArticleByFeedIdWhenIsUnread(accountId, content, feedId, true) | ||||
|                 else -> articleDao.searchArticleByFeedIdWhenAll(accountId, content, feedId) | ||||
|             } | ||||
| 
 | ||||
|             else -> when { | ||||
|                 isStarred -> articleDao.searchArticleWhenIsStarred(accountId, content, isStarred) | ||||
|                 isUnread -> articleDao.searchArticleWhenIsUnread(accountId, content, isUnread) | ||||
|                 isStarred -> articleDao.searchArticleWhenIsStarred(accountId, content, true) | ||||
|                 isUnread -> articleDao.searchArticleWhenIsUnread(accountId, content, true) | ||||
|                 else -> articleDao.searchArticleWhenAll(accountId, content) | ||||
|             } | ||||
|         } | ||||
|  | ||||
| @ -5,8 +5,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import me.ash.reader.R | ||||
| import me.ash.reader.data.dao.AccountDao | ||||
| import me.ash.reader.data.dao.GroupDao | ||||
| import me.ash.reader.data.entity.Account | ||||
| import me.ash.reader.data.entity.Group | ||||
| import me.ash.reader.data.model.account.Account | ||||
| import me.ash.reader.data.model.account.AccountType | ||||
| import me.ash.reader.data.model.group.Group | ||||
| import me.ash.reader.ui.ext.currentAccountId | ||||
| import me.ash.reader.ui.ext.getDefaultGroupId | ||||
| import javax.inject.Inject | ||||
| @ -18,20 +19,16 @@ class AccountRepository @Inject constructor( | ||||
|     private val groupDao: GroupDao, | ||||
| ) { | ||||
| 
 | ||||
|     suspend fun getCurrentAccount(): Account? { | ||||
|         return accountDao.queryById(context.currentAccountId) | ||||
|     } | ||||
|     suspend fun getCurrentAccount(): Account? = accountDao.queryById(context.currentAccountId) | ||||
| 
 | ||||
|     suspend fun isNoAccount(): Boolean { | ||||
|         return accountDao.queryAll().isEmpty() | ||||
|     } | ||||
|     suspend fun isNoAccount(): Boolean = accountDao.queryAll().isEmpty() | ||||
| 
 | ||||
|     suspend fun addDefaultAccount(): Account { | ||||
|         val readYouString = context.getString(R.string.read_you) | ||||
|         val defaultString = context.getString(R.string.defaults) | ||||
|         return Account( | ||||
|             name = readYouString, | ||||
|             type = Account.Type.LOCAL, | ||||
|             type = AccountType.Local, | ||||
|         ).apply { | ||||
|             id = accountDao.insert(this).toInt() | ||||
|         }.also { | ||||
|  | ||||
| @ -14,12 +14,12 @@ 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.entity.Article | ||||
| import me.ash.reader.data.entity.Feed | ||||
| import me.ash.reader.data.entity.FeedWithArticle | ||||
| import me.ash.reader.data.entity.Group | ||||
| import me.ash.reader.data.module.DispatcherDefault | ||||
| import me.ash.reader.data.module.DispatcherIO | ||||
| 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 | ||||
| @ -35,14 +35,14 @@ class LocalRssRepository @Inject constructor( | ||||
|     private val notificationHelper: NotificationHelper, | ||||
|     private val accountDao: AccountDao, | ||||
|     private val groupDao: GroupDao, | ||||
|     @DispatcherIO | ||||
|     private val dispatcherIO: CoroutineDispatcher, | ||||
|     @DispatcherDefault | ||||
|     private val dispatcherDefault: CoroutineDispatcher, | ||||
|     @IODispatcher | ||||
|     private val ioDispatcher: CoroutineDispatcher, | ||||
|     @DefaultDispatcher | ||||
|     private val defaultDispatcher: CoroutineDispatcher, | ||||
|     workManager: WorkManager, | ||||
| ) : AbstractRssRepository( | ||||
|     context, accountDao, articleDao, groupDao, | ||||
|     feedDao, workManager, dispatcherIO, dispatcherDefault | ||||
|     feedDao, workManager, ioDispatcher, defaultDispatcher | ||||
| ) { | ||||
| 
 | ||||
|     override suspend fun updateArticleInfo(article: Article) { | ||||
| @ -70,8 +70,8 @@ class LocalRssRepository @Inject constructor( | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result { | ||||
|         return supervisorScope { | ||||
|     override suspend fun sync(coroutineWorker: CoroutineWorker): ListenableWorker.Result = | ||||
|         supervisorScope { | ||||
|             val preTime = System.currentTimeMillis() | ||||
|             val accountId = context.currentAccountId | ||||
|             feedDao.queryAll(accountId) | ||||
| @ -81,31 +81,23 @@ class LocalRssRepository @Inject constructor( | ||||
|                     it.map { feed -> async { syncFeed(feed) } } | ||||
|                         .awaitAll() | ||||
|                         .forEach { | ||||
|                             if (it.isNotify) { | ||||
|                                 notificationHelper.notify( | ||||
|                                     FeedWithArticle( | ||||
|                                         it.feedWithArticle.feed, | ||||
|                                         articleDao.insertListIfNotExist(it.feedWithArticle.articles) | ||||
|                                     ) | ||||
|                                 ) | ||||
|                             if (it.feed.isNotification) { | ||||
|                                 notificationHelper.notify(it.apply { | ||||
|                                     articles = articleDao.insertListIfNotExist(it.articles) | ||||
|                                 }) | ||||
|                             } else { | ||||
|                                 articleDao.insertListIfNotExist(it.feedWithArticle.articles) | ||||
|                                 articleDao.insertListIfNotExist(it.articles) | ||||
|                             } | ||||
|                         } | ||||
|                 } | ||||
| 
 | ||||
|             Log.i("RlOG", "onCompletion: ${System.currentTimeMillis() - preTime}") | ||||
|             accountDao.queryById(accountId)?.let { account -> | ||||
|                 accountDao.update( | ||||
|                     account.apply { | ||||
|                         updateAt = Date() | ||||
|                     } | ||||
|                 ) | ||||
|                 accountDao.update(account.apply { updateAt = Date() }) | ||||
|             } | ||||
|             coroutineWorker.setProgress(setIsSyncing(false)) | ||||
|             ListenableWorker.Result.success() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     override suspend fun markAsRead( | ||||
|         groupId: String?, | ||||
| @ -124,6 +116,7 @@ class LocalRssRepository @Inject constructor( | ||||
|                     before = before ?: Date(Long.MAX_VALUE) | ||||
|                 ) | ||||
|             } | ||||
| 
 | ||||
|             feedId != null -> { | ||||
|                 articleDao.markAllAsReadByFeedId( | ||||
|                     accountId = accountId, | ||||
| @ -132,41 +125,34 @@ class LocalRssRepository @Inject constructor( | ||||
|                     before = before ?: Date(Long.MAX_VALUE) | ||||
|                 ) | ||||
|             } | ||||
| 
 | ||||
|             articleId != null -> { | ||||
|                 articleDao.markAsReadByArticleId(accountId, articleId, isUnread) | ||||
|             } | ||||
| 
 | ||||
|             else -> { | ||||
|                 articleDao.markAllAsRead(accountId, isUnread, before ?: Date(Long.MAX_VALUE)) | ||||
|             } | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     data class ArticleNotify( | ||||
|         val feedWithArticle: FeedWithArticle, | ||||
|         val isNotify: Boolean, | ||||
|     ) | ||||
| 
 | ||||
|     private suspend fun syncFeed(feed: Feed): ArticleNotify { | ||||
|     private suspend fun syncFeed(feed: Feed): FeedWithArticle { | ||||
|         val latest = articleDao.queryLatestByFeedId(context.currentAccountId, feed.id) | ||||
|         val articles: List<Article>? | ||||
|         try { | ||||
|             articles = rssHelper.queryRssXml(feed, latest?.link) | ||||
|         } catch (e: Exception) { | ||||
|             e.printStackTrace() | ||||
|             Log.e("RLog", "queryRssXml[${feed.name}]: ${e.message}") | ||||
|             return ArticleNotify(FeedWithArticle(feed, listOf()), false) | ||||
|         } | ||||
|         try { | ||||
|         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 ArticleNotify(FeedWithArticle(feed, listOf()), false) | ||||
|         } | ||||
|         return ArticleNotify( | ||||
|             feedWithArticle = FeedWithArticle(feed, articles), | ||||
|             isNotify = articles.isNotEmpty() && feed.isNotification | ||||
| //        } 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 | ||||
|         ) | ||||
|     } | ||||
| } | ||||
| @ -9,7 +9,7 @@ import androidx.core.app.NotificationManagerCompat | ||||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import me.ash.reader.MainActivity | ||||
| import me.ash.reader.R | ||||
| import me.ash.reader.data.entity.FeedWithArticle | ||||
| import me.ash.reader.data.model.feed.FeedWithArticle | ||||
| import me.ash.reader.ui.page.common.ExtraName | ||||
| import me.ash.reader.ui.page.common.NotificationGroupName | ||||
| import java.util.* | ||||
| @ -19,6 +19,7 @@ class NotificationHelper @Inject constructor( | ||||
|     @ApplicationContext | ||||
|     private val context: Context, | ||||
| ) { | ||||
| 
 | ||||
|     private val notificationManager: NotificationManagerCompat = | ||||
|         NotificationManagerCompat.from(context).apply { | ||||
|             createNotificationChannel( | ||||
| @ -30,9 +31,7 @@ class NotificationHelper @Inject constructor( | ||||
|             ) | ||||
|         } | ||||
| 
 | ||||
|     fun notify( | ||||
|         feedWithArticle: FeedWithArticle, | ||||
|     ) { | ||||
|     fun notify(feedWithArticle: FeedWithArticle) { | ||||
|         notificationManager.createNotificationChannelGroup( | ||||
|             NotificationChannelGroup( | ||||
|                 feedWithArticle.feed.id, | ||||
|  | ||||
| @ -10,7 +10,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| 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.entity.Feed | ||||
| import me.ash.reader.data.model.feed.Feed | ||||
| import me.ash.reader.data.source.OpmlLocalDataSource | ||||
| import me.ash.reader.ui.ext.currentAccountId | ||||
| import me.ash.reader.ui.ext.getDefaultGroupId | ||||
| @ -18,6 +18,9 @@ import java.io.InputStream | ||||
| import java.util.* | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| /** | ||||
|  * Supports import and export from OPML files. | ||||
|  */ | ||||
| class OpmlRepository @Inject constructor( | ||||
|     @ApplicationContext | ||||
|     private val context: Context, | ||||
| @ -27,6 +30,12 @@ class OpmlRepository @Inject constructor( | ||||
|     private val rssRepository: RssRepository, | ||||
|     private val opmlLocalDataSource: OpmlLocalDataSource, | ||||
| ) { | ||||
| 
 | ||||
|     /** | ||||
|      * Imports OPML file. | ||||
|      * | ||||
|      * @param [inputStream] input stream of OPML file | ||||
|      */ | ||||
|     @Throws(Exception::class) | ||||
|     suspend fun saveToDatabase(inputStream: InputStream) { | ||||
|         val defaultGroup = groupDao.queryById(getDefaultGroupId())!! | ||||
| @ -47,6 +56,9 @@ class OpmlRepository @Inject constructor( | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     /** | ||||
|      * Exports OPML file. | ||||
|      */ | ||||
|     @Throws(Exception::class) | ||||
|     suspend fun saveToString(): String { | ||||
|         val defaultGroup = groupDao.queryById(getDefaultGroupId())!! | ||||
| @ -85,7 +97,5 @@ class OpmlRepository @Inject constructor( | ||||
|         )!! | ||||
|     } | ||||
| 
 | ||||
|     private fun getDefaultGroupId(): String { | ||||
|         return context.currentAccountId.getDefaultGroupId() | ||||
|     } | ||||
|     private fun getDefaultGroupId(): String = context.currentAccountId.getDefaultGroupId() | ||||
| } | ||||
| @ -8,11 +8,11 @@ import kotlinx.coroutines.flow.Flow | ||||
| import kotlinx.coroutines.flow.emptyFlow | ||||
| import kotlinx.coroutines.withContext | ||||
| import me.ash.reader.R | ||||
| import me.ash.reader.data.model.toVersion | ||||
| import me.ash.reader.data.module.DispatcherIO | ||||
| import me.ash.reader.data.module.DispatcherMain | ||||
| import me.ash.reader.data.preference.* | ||||
| import me.ash.reader.data.preference.NewVersionSizePreference.formatSize | ||||
| import me.ash.reader.data.model.general.toVersion | ||||
| import me.ash.reader.data.model.preference.* | ||||
| import me.ash.reader.data.model.preference.NewVersionSizePreference.formatSize | ||||
| import me.ash.reader.data.module.IODispatcher | ||||
| import me.ash.reader.data.module.MainDispatcher | ||||
| import me.ash.reader.data.source.Download | ||||
| import me.ash.reader.data.source.RYNetworkDataSource | ||||
| import me.ash.reader.data.source.downloadToFileWithProgress | ||||
| @ -26,34 +26,35 @@ class RYRepository @Inject constructor( | ||||
|     @ApplicationContext | ||||
|     private val context: Context, | ||||
|     private val RYNetworkDataSource: RYNetworkDataSource, | ||||
|     @DispatcherIO | ||||
|     private val dispatcherIO: CoroutineDispatcher, | ||||
|     @DispatcherMain | ||||
|     private val dispatcherMain: CoroutineDispatcher, | ||||
|     @IODispatcher | ||||
|     private val ioDispatcher: CoroutineDispatcher, | ||||
|     @MainDispatcher | ||||
|     private val mainDispatcher: CoroutineDispatcher, | ||||
| ) { | ||||
|     suspend fun checkUpdate(showToast: Boolean = true): Boolean? = withContext(dispatcherIO) { | ||||
| 
 | ||||
|     suspend fun checkUpdate(showToast: Boolean = true): Boolean? = withContext(ioDispatcher) { | ||||
|         try { | ||||
|             val response = | ||||
|                 RYNetworkDataSource.getReleaseLatest(context.getString(R.string.update_link)) | ||||
|             val response = RYNetworkDataSource.getReleaseLatest(context.getString(R.string.update_link)) | ||||
|             when { | ||||
|                 response.code() == 403 -> { | ||||
|                     withContext(dispatcherMain) { | ||||
|                     withContext(mainDispatcher) { | ||||
|                         if (showToast) context.showToast(context.getString(R.string.rate_limit)) | ||||
|                     } | ||||
|                     return@withContext null | ||||
|                 } | ||||
| 
 | ||||
|                 response.body() == null -> { | ||||
|                     withContext(dispatcherMain) { | ||||
|                     withContext(mainDispatcher) { | ||||
|                         if (showToast) context.showToast(context.getString(R.string.check_failure)) | ||||
|                     } | ||||
|                     return@withContext null | ||||
|                 } | ||||
|             } | ||||
|             val skipVersion = context.skipVersionNumber.toVersion() | ||||
|             val currentVersion = context.getCurrentVersion() | ||||
|             val latest = response.body()!! | ||||
|             val latestVersion = latest.tag_name.toVersion() | ||||
| //            val latestVersion = "1.0.0".toVersion() | ||||
|             val skipVersion = context.skipVersionNumber.toVersion() | ||||
|             val currentVersion = context.getCurrentVersion() | ||||
|             val latestLog = latest.body ?: "" | ||||
|             val latestPublishDate = latest.published_at ?: latest.created_at ?: "" | ||||
|             val latestSize = latest.assets?.first()?.size ?: 0 | ||||
| @ -74,7 +75,7 @@ class RYRepository @Inject constructor( | ||||
|         } catch (e: Exception) { | ||||
|             e.printStackTrace() | ||||
|             Log.e("RLog", "checkUpdate: ${e.message}") | ||||
|             withContext(dispatcherMain) { | ||||
|             withContext(mainDispatcher) { | ||||
|                 if (showToast) context.showToast(context.getString(R.string.check_failure)) | ||||
|             } | ||||
|             null | ||||
| @ -82,7 +83,7 @@ class RYRepository @Inject constructor( | ||||
|     } | ||||
| 
 | ||||
|     suspend fun downloadFile(url: String): Flow<Download> = | ||||
|         withContext(dispatcherIO) { | ||||
|         withContext(ioDispatcher) { | ||||
|             Log.i("RLog", "downloadFile start: $url") | ||||
|             try { | ||||
|                 return@withContext RYNetworkDataSource.downloadFile(url) | ||||
| @ -90,7 +91,7 @@ class RYRepository @Inject constructor( | ||||
|             } catch (e: Exception) { | ||||
|                 e.printStackTrace() | ||||
|                 Log.e("RLog", "downloadFile: ${e.message}") | ||||
|                 withContext(dispatcherMain) { | ||||
|                 withContext(mainDispatcher) { | ||||
|                     context.showToast(context.getString(R.string.download_failure)) | ||||
|                 } | ||||
|             } | ||||
|  | ||||
| @ -10,13 +10,12 @@ import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import kotlinx.coroutines.CoroutineDispatcher | ||||
| import kotlinx.coroutines.withContext | ||||
| import me.ash.reader.data.dao.FeedDao | ||||
| import me.ash.reader.data.entity.Article | ||||
| import me.ash.reader.data.entity.Feed | ||||
| import me.ash.reader.data.entity.FeedWithArticle | ||||
| import me.ash.reader.data.module.DispatcherIO | ||||
| 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.module.IODispatcher | ||||
| import me.ash.reader.ui.ext.currentAccountId | ||||
| import me.ash.reader.ui.ext.spacerDollar | ||||
| import net.dankito.readability4j.Readability4J | ||||
| import net.dankito.readability4j.extended.Readability4JExtended | ||||
| import okhttp3.OkHttpClient | ||||
| import okhttp3.Request | ||||
| @ -25,16 +24,20 @@ import java.io.InputStream | ||||
| import java.util.* | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| /** | ||||
|  * Some operations on RSS. | ||||
|  */ | ||||
| class RssHelper @Inject constructor( | ||||
|     @ApplicationContext | ||||
|     private val context: Context, | ||||
|     @DispatcherIO | ||||
|     private val dispatcherIO: CoroutineDispatcher, | ||||
|     @IODispatcher | ||||
|     private val ioDispatcher: CoroutineDispatcher, | ||||
|     private val okHttpClient: OkHttpClient, | ||||
| ) { | ||||
| 
 | ||||
|     @Throws(Exception::class) | ||||
|     suspend fun searchFeed(feedLink: String): FeedWithArticle { | ||||
|         return withContext(dispatcherIO) { | ||||
|         return withContext(ioDispatcher) { | ||||
|             val accountId = context.currentAccountId | ||||
|             val syndFeed = SyndFeedInput().build(XmlReader(inputStream(okHttpClient, feedLink))) | ||||
|             val feed = Feed( | ||||
| @ -49,16 +52,9 @@ class RssHelper @Inject constructor( | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     fun parseDescriptionContent(link: String, content: String): String { | ||||
|         val readability4J: Readability4J = Readability4JExtended(link, content) | ||||
|         val article = readability4J.parse() | ||||
|         val element = article.articleContent | ||||
|         return element.toString() | ||||
|     } | ||||
| 
 | ||||
|     @Throws(Exception::class) | ||||
|     suspend fun parseFullContent(link: String, title: String): String { | ||||
|         return withContext(dispatcherIO) { | ||||
|         return withContext(ioDispatcher) { | ||||
|             val response = response(okHttpClient, link) | ||||
|             val content = response.body.string() | ||||
|             val readability4J = Readability4JExtended(link, content) | ||||
| @ -75,27 +71,31 @@ class RssHelper @Inject constructor( | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     @Throws(Exception::class) | ||||
|     suspend fun queryRssXml( | ||||
|         feed: Feed, | ||||
|         latestLink: String? = null, | ||||
|     ): List<Article> { | ||||
|         val accountId = context.currentAccountId | ||||
|         return inputStream(okHttpClient, feed.url).use { | ||||
|             SyndFeedInput().apply { isPreserveWireFeed = true } | ||||
|                 .build(XmlReader(it)) | ||||
|                 .entries | ||||
|                 .asSequence() | ||||
|                 .takeWhile { latestLink == null || latestLink != it.link } | ||||
|                 .map { article(feed, accountId, it) } | ||||
|                 .toList() | ||||
|         latestLink: String?, | ||||
|     ): List<Article> = | ||||
|         try { | ||||
|             val accountId = context.currentAccountId | ||||
|             inputStream(okHttpClient, feed.url).use { | ||||
|                 SyndFeedInput().apply { isPreserveWireFeed = true } | ||||
|                     .build(XmlReader(it)) | ||||
|                     .entries | ||||
|                     .asSequence() | ||||
|                     .takeWhile { latestLink == null || latestLink != it.link } | ||||
|                     .map { article(feed, accountId, it) } | ||||
|                     .toList() | ||||
|             } | ||||
|         } catch (e: Exception) { | ||||
|             e.printStackTrace() | ||||
|             Log.e("RLog", "queryRssXml[${feed.name}]: ${e.message}") | ||||
|             listOf() | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     private fun article( | ||||
|         feed: Feed, | ||||
|         accountId: Int, | ||||
|         syndEntry: SyndEntry | ||||
|         syndEntry: SyndEntry, | ||||
|     ): Article { | ||||
|         val desc = syndEntry.description?.value | ||||
|         val content = syndEntry.contents | ||||
| @ -144,7 +144,7 @@ class RssHelper @Inject constructor( | ||||
|         feed: Feed, | ||||
|         articleLink: String, | ||||
|     ) { | ||||
|         withContext(dispatcherIO) { | ||||
|         withContext(ioDispatcher) { | ||||
|             val domainRegex = Regex("(http|https)://(www.)?(\\w+(\\.)?)+") | ||||
|             val request = response(okHttpClient, articleLink) | ||||
|             val content = request.body.string() | ||||
| @ -183,11 +183,11 @@ class RssHelper @Inject constructor( | ||||
| 
 | ||||
|     private suspend fun inputStream( | ||||
|         client: OkHttpClient, | ||||
|         url: String | ||||
|         url: String, | ||||
|     ): InputStream = response(client, url).body.byteStream() | ||||
| 
 | ||||
|     private suspend fun response( | ||||
|         client: OkHttpClient, | ||||
|         url: String | ||||
|         url: String, | ||||
|     ) = client.newCall(Request.Builder().url(url).build()).executeAsync() | ||||
| } | ||||
|  | ||||
| @ -2,7 +2,7 @@ package me.ash.reader.data.repository | ||||
| 
 | ||||
| import android.content.Context | ||||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import me.ash.reader.data.entity.Account | ||||
| import me.ash.reader.data.model.account.AccountType | ||||
| import me.ash.reader.ui.ext.currentAccountType | ||||
| import javax.inject.Inject | ||||
| 
 | ||||
| @ -13,8 +13,9 @@ class RssRepository @Inject constructor( | ||||
| //    private val feverRssRepository: FeverRssRepository, | ||||
| //    private val googleReaderRssRepository: GoogleReaderRssRepository, | ||||
| ) { | ||||
| 
 | ||||
|     fun get() = when (context.currentAccountType) { | ||||
|         Account.Type.LOCAL -> localRssRepository | ||||
|         AccountType.Local.id -> localRssRepository | ||||
| //        Account.Type.LOCAL -> feverRssRepository | ||||
| //        Account.Type.FEVER -> feverRssRepository | ||||
| //        Account.Type.GOOGLE_READER -> googleReaderRssRepository | ||||
|  | ||||
| @ -10,6 +10,7 @@ class StringsRepository @Inject constructor( | ||||
|     @ApplicationContext | ||||
|     private val context: Context, | ||||
| ) { | ||||
| 
 | ||||
|     fun getString(resId: Int, vararg formatArgs: Any) = context.getString(resId, *formatArgs) | ||||
| 
 | ||||
|     fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any) = | ||||
| @ -18,6 +19,6 @@ class StringsRepository @Inject constructor( | ||||
|     fun formatAsString( | ||||
|         date: Date?, | ||||
|         onlyHourMinute: Boolean? = false, | ||||
|         atHourMinute: Boolean? = false | ||||
|         atHourMinute: Boolean? = false, | ||||
|     ) = date?.formatAsString(context, onlyHourMinute, atHourMinute) | ||||
| } | ||||
|  | ||||
| @ -18,17 +18,17 @@ class SyncWorker @AssistedInject constructor( | ||||
|     private val rssRepository: RssRepository, | ||||
| ) : CoroutineWorker(context, workerParams) { | ||||
| 
 | ||||
|     override suspend fun doWork(): Result { | ||||
|         Log.i("RLog", "doWork: ") | ||||
|         return withContext(Dispatchers.Default) { | ||||
|     override suspend fun doWork(): Result = | ||||
|         withContext(Dispatchers.Default) { | ||||
|             Log.i("RLog", "doWork: ") | ||||
|             rssRepository.get().sync(this@SyncWorker) | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         const val WORK_NAME = "article.sync" | ||||
| 
 | ||||
|         val UUID: UUID | ||||
|         val uuid: UUID | ||||
| 
 | ||||
|         val repeatingRequest = PeriodicWorkRequestBuilder<SyncWorker>( | ||||
|             15, TimeUnit.MINUTES | ||||
| @ -36,7 +36,7 @@ class SyncWorker @AssistedInject constructor( | ||||
|             Constraints.Builder() | ||||
|                 .build() | ||||
|         ).addTag(WORK_NAME).build().also { | ||||
|             UUID = it.id | ||||
|             uuid = it.id | ||||
|         } | ||||
| 
 | ||||
|         fun setIsSyncing(boolean: Boolean) = workDataOf("isSyncing" to boolean) | ||||
|  | ||||
| @ -11,6 +11,7 @@ import retrofit2.http.Part | ||||
| import retrofit2.http.Query | ||||
| 
 | ||||
| interface FeverApiDataSource { | ||||
| 
 | ||||
|     @Multipart | ||||
|     @POST("fever.php/?api=&feeds=") | ||||
|     fun feeds(@Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody()): Call<FeverApiDto.Feed> | ||||
| @ -23,7 +24,7 @@ interface FeverApiDataSource { | ||||
|     @POST("fever.php/?api=&items=") | ||||
|     fun itemsBySince( | ||||
|         @Query("since_id") since: Long, | ||||
|         @Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody() | ||||
|         @Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody(), | ||||
|     ): Call<FeverApiDto.Items> | ||||
| 
 | ||||
|     @Multipart | ||||
| @ -38,10 +39,11 @@ interface FeverApiDataSource { | ||||
|     @POST("fever.php/?api=&items=") | ||||
|     fun itemsByIds( | ||||
|         @Query("with_ids") ids: String, | ||||
|         @Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody() | ||||
|         @Part("api_key") apiKey: RequestBody? = "1352b707f828a6f502db3768fa8d7151".toRequestBody(), | ||||
|     ): Call<FeverApiDto.Items> | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         private var instance: FeverApiDataSource? = null | ||||
| 
 | ||||
|         fun getInstance(): FeverApiDataSource { | ||||
|  | ||||
| @ -7,6 +7,7 @@ import retrofit2.http.Headers | ||||
| import retrofit2.http.POST | ||||
| 
 | ||||
| interface GoogleReaderApiDataSource { | ||||
| 
 | ||||
|     @POST("accounts/ClientLogin") | ||||
|     fun login(Email: String, Passwd: String): Call<String> | ||||
| 
 | ||||
| @ -27,6 +28,7 @@ interface GoogleReaderApiDataSource { | ||||
|     fun readingList(): Call<GoogleReaderApiDto.ReadingList> | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         private var instance: GoogleReaderApiDataSource? = null | ||||
| 
 | ||||
|         fun getInstance(): GoogleReaderApiDataSource { | ||||
|  | ||||
| @ -5,10 +5,10 @@ import be.ceau.opml.OpmlParser | ||||
| import dagger.hilt.android.qualifiers.ApplicationContext | ||||
| import kotlinx.coroutines.CoroutineDispatcher | ||||
| import kotlinx.coroutines.withContext | ||||
| import me.ash.reader.data.entity.Feed | ||||
| import me.ash.reader.data.entity.Group | ||||
| import me.ash.reader.data.entity.GroupWithFeed | ||||
| import me.ash.reader.data.module.DispatcherIO | ||||
| import me.ash.reader.data.model.feed.Feed | ||||
| import me.ash.reader.data.model.group.Group | ||||
| import me.ash.reader.data.model.group.GroupWithFeed | ||||
| import me.ash.reader.data.module.IODispatcher | ||||
| import me.ash.reader.ui.ext.currentAccountId | ||||
| import me.ash.reader.ui.ext.spacerDollar | ||||
| import java.io.InputStream | ||||
| @ -18,15 +18,16 @@ import javax.inject.Inject | ||||
| class OpmlLocalDataSource @Inject constructor( | ||||
|     @ApplicationContext | ||||
|     private val context: Context, | ||||
|     @DispatcherIO | ||||
|     private val dispatcherIO: CoroutineDispatcher, | ||||
|     @IODispatcher | ||||
|     private val ioDispatcher: CoroutineDispatcher, | ||||
| ) { | ||||
| 
 | ||||
|     @Throws(Exception::class) | ||||
|     suspend fun parseFileInputStream( | ||||
|         inputStream: InputStream, | ||||
|         defaultGroup: Group | ||||
|         defaultGroup: Group, | ||||
|     ): List<GroupWithFeed> { | ||||
|         return withContext(dispatcherIO) { | ||||
|         return withContext(ioDispatcher) { | ||||
|             val accountId = context.currentAccountId | ||||
|             val opml = OpmlParser().parse(inputStream) | ||||
|             val groupWithFeedList = mutableListOf<GroupWithFeed>().also { | ||||
|  | ||||
| @ -8,24 +8,27 @@ 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.entity.Account | ||||
| import me.ash.reader.data.entity.Article | ||||
| import me.ash.reader.data.entity.Feed | ||||
| import me.ash.reader.data.entity.Group | ||||
| import me.ash.reader.data.model.account.Account | ||||
| import me.ash.reader.data.model.account.AccountTypeConverters | ||||
| 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 java.util.* | ||||
| 
 | ||||
| @Database( | ||||
|     entities = [Account::class, Feed::class, Article::class, Group::class], | ||||
|     version = 2, | ||||
|     version = 2 | ||||
| ) | ||||
| @TypeConverters(RYDatabase.Converters::class) | ||||
| @TypeConverters(RYDatabase.DateConverters::class, AccountTypeConverters::class) | ||||
| abstract class RYDatabase : RoomDatabase() { | ||||
| 
 | ||||
|     abstract fun accountDao(): AccountDao | ||||
|     abstract fun feedDao(): FeedDao | ||||
|     abstract fun articleDao(): ArticleDao | ||||
|     abstract fun groupDao(): GroupDao | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         private var instance: RYDatabase? = null | ||||
| 
 | ||||
|         fun getInstance(context: Context): RYDatabase { | ||||
| @ -41,7 +44,7 @@ abstract class RYDatabase : RoomDatabase() { | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     class Converters { | ||||
|     class DateConverters { | ||||
| 
 | ||||
|         @TypeConverter | ||||
|         fun toDate(dateLong: Long?): Date? { | ||||
| @ -61,6 +64,7 @@ val allMigrations = arrayOf( | ||||
| 
 | ||||
| @Suppress("ClassName") | ||||
| object MIGRATION_1_2 : Migration(1, 2) { | ||||
| 
 | ||||
|     override fun migrate(database: SupportSQLiteDatabase) { | ||||
|         database.execSQL( | ||||
|             """ | ||||
|  | ||||
| @ -15,6 +15,7 @@ import retrofit2.http.Url | ||||
| import java.io.File | ||||
| 
 | ||||
| interface RYNetworkDataSource { | ||||
| 
 | ||||
|     @GET | ||||
|     suspend fun getReleaseLatest(@Url url: String): Response<LatestRelease> | ||||
| 
 | ||||
| @ -23,6 +24,7 @@ interface RYNetworkDataSource { | ||||
|     suspend fun downloadFile(@Url url: String): ResponseBody | ||||
| 
 | ||||
|     companion object { | ||||
| 
 | ||||
|         private var instance: RYNetworkDataSource? = null | ||||
| 
 | ||||
|         fun getInstance(): RYNetworkDataSource { | ||||
| @ -69,8 +71,10 @@ fun ResponseBody.downloadToFileWithProgress(saveFile: File): Flow<Download> = | ||||
|                     when { | ||||
|                         progressBytes < totalBytes -> | ||||
|                             throw Exception("missing bytes") | ||||
| 
 | ||||
|                         progressBytes > totalBytes -> | ||||
|                             throw Exception("too many bytes") | ||||
| 
 | ||||
|                         else -> | ||||
|                             deleteFile = false | ||||
|                     } | ||||
|  | ||||
| @ -0,0 +1,28 @@ | ||||
| package me.ash.reader.ui.component | ||||
| 
 | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.Edit | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import me.ash.reader.R | ||||
| import me.ash.reader.ui.component.base.TextFieldDialog | ||||
| 
 | ||||
| @Composable | ||||
| fun ChangeUrlDialog( | ||||
|     visible: Boolean = false, | ||||
|     value: String = "", | ||||
|     onValueChange: (String) -> Unit = {}, | ||||
|     onDismissRequest: () -> Unit = {}, | ||||
|     onConfirm: (String) -> Unit = {}, | ||||
| ) { | ||||
|     TextFieldDialog( | ||||
|         visible = visible, | ||||
|         title = stringResource(R.string.change_url), | ||||
|         icon = Icons.Outlined.Edit, | ||||
|         value = value, | ||||
|         placeholder = stringResource(R.string.feed_url_placeholder), | ||||
|         onValueChange = onValueChange, | ||||
|         onDismissRequest = onDismissRequest, | ||||
|         onConfirm = onConfirm, | ||||
|     ) | ||||
| } | ||||
| @ -14,7 +14,7 @@ import androidx.compose.ui.unit.dp | ||||
| @Composable | ||||
| fun FeedIcon( | ||||
|     feedName: String, | ||||
|     size: Dp = 20.dp | ||||
|     size: Dp = 20.dp, | ||||
| ) { | ||||
|     Row( | ||||
|         modifier = Modifier | ||||
|  | ||||
| @ -12,10 +12,9 @@ import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.platform.LocalView | ||||
| import androidx.compose.ui.text.style.TextOverflow | ||||
| import androidx.compose.ui.unit.Dp | ||||
| import me.ash.reader.data.model.Filter | ||||
| import me.ash.reader.data.model.getName | ||||
| import me.ash.reader.data.preference.FlowFilterBarStylePreference | ||||
| import me.ash.reader.data.preference.LocalThemeIndex | ||||
| import me.ash.reader.data.model.general.Filter | ||||
| import me.ash.reader.data.model.preference.FlowFilterBarStylePreference | ||||
| import me.ash.reader.data.model.preference.LocalThemeIndex | ||||
| import me.ash.reader.ui.ext.surfaceColorAtElevation | ||||
| import me.ash.reader.ui.theme.palette.onDark | ||||
| 
 | ||||
| @ -54,7 +53,7 @@ fun FilterBar( | ||||
|                         } else { | ||||
|                             item.iconOutline | ||||
|                         }, | ||||
|                         contentDescription = item.getName() | ||||
|                         contentDescription = item.toName() | ||||
|                     ) | ||||
|                 }, | ||||
|                 label = if (filterBarStyle == FlowFilterBarStylePreference.Icon.value) { | ||||
| @ -62,7 +61,7 @@ fun FilterBar( | ||||
|                 } else { | ||||
|                     { | ||||
|                         Text( | ||||
|                             text = item.getName(), | ||||
|                             text = item.toName(), | ||||
|                             style = MaterialTheme.typography.labelLarge, | ||||
|                             maxLines = 1, | ||||
|                             overflow = TextOverflow.Ellipsis, | ||||
|  | ||||
							
								
								
									
										28
									
								
								app/src/main/java/me/ash/reader/ui/component/RenameDialog.kt
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/src/main/java/me/ash/reader/ui/component/RenameDialog.kt
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| package me.ash.reader.ui.component | ||||
| 
 | ||||
| import androidx.compose.material.icons.Icons | ||||
| import androidx.compose.material.icons.outlined.Edit | ||||
| import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.res.stringResource | ||||
| import me.ash.reader.R | ||||
| import me.ash.reader.ui.component.base.TextFieldDialog | ||||
| 
 | ||||
| @Composable | ||||
| fun RenameDialog( | ||||
|     visible: Boolean = false, | ||||
|     value: String = "", | ||||
|     onValueChange: (String) -> Unit = {}, | ||||
|     onDismissRequest: () -> Unit = {}, | ||||
|     onConfirm: (String) -> Unit = {}, | ||||
| ) { | ||||
|     TextFieldDialog( | ||||
|         visible = visible, | ||||
|         title = stringResource(R.string.rename), | ||||
|         icon = Icons.Outlined.Edit, | ||||
|         value = value, | ||||
|         placeholder = stringResource(R.string.name), | ||||
|         onValueChange = onValueChange, | ||||
|         onDismissRequest = onDismissRequest, | ||||
|         onConfirm = onConfirm | ||||
|     ) | ||||
| } | ||||
| @ -29,7 +29,7 @@ fun AnimatedPopup( | ||||
|                 anchorBounds: IntRect, | ||||
|                 windowSize: IntSize, | ||||
|                 layoutDirection: LayoutDirection, | ||||
|                 popupContentSize: IntSize | ||||
|                 popupContentSize: IntSize, | ||||
|             ): IntOffset { | ||||
|                 return IntOffset( | ||||
|                     x = with(density) { (absoluteX).roundToPx() }, | ||||
|  | ||||
| @ -36,7 +36,7 @@ fun AnimatedText( | ||||
|     softWrap: Boolean = true, | ||||
|     maxLines: Int = Int.MAX_VALUE, | ||||
|     onTextLayout: (TextLayoutResult) -> Unit = {}, | ||||
|     style: TextStyle = LocalTextStyle.current | ||||
|     style: TextStyle = LocalTextStyle.current, | ||||
| ) { | ||||
|     AnimatedContent( | ||||
|         targetState = text, | ||||
|  | ||||
| @ -76,7 +76,7 @@ fun ClipboardTextField( | ||||
| private fun action( | ||||
|     focusManager: FocusManager?, | ||||
|     onConfirm: (String) -> Unit, | ||||
|     value: String | ||||
|     value: String, | ||||
| ): KeyboardActionScope.() -> Unit = { | ||||
|     focusManager?.clearFocus() | ||||
|     onConfirm(value) | ||||
|  | ||||
| @ -22,13 +22,14 @@ class CurlyCornerShape( | ||||
|     bottomEnd = ZeroCornerSize, | ||||
|     bottomStart = ZeroCornerSize | ||||
| ) { | ||||
| 
 | ||||
|     private fun sineCircleXYatAngle( | ||||
|         d1: Double, | ||||
|         d2: Double, | ||||
|         d3: Double, | ||||
|         d4: Double, | ||||
|         d5: Double, | ||||
|         i: Int | ||||
|         i: Int, | ||||
|     ): List<Double> = (i.toDouble() * d5).run { | ||||
|         listOf( | ||||
|             (sin(this) * d4 + d3) * cos(d5) + d1, | ||||
| @ -42,7 +43,7 @@ class CurlyCornerShape( | ||||
|         topEnd: Float, | ||||
|         bottomEnd: Float, | ||||
|         bottomStart: Float, | ||||
|         layoutDirection: LayoutDirection | ||||
|         layoutDirection: LayoutDirection, | ||||
|     ): Outline { | ||||
|         val d = 2.0 | ||||
|         val r2: Double = size.width / d | ||||
| @ -73,7 +74,7 @@ class CurlyCornerShape( | ||||
|         topStart: CornerSize, | ||||
|         topEnd: CornerSize, | ||||
|         bottomEnd: CornerSize, | ||||
|         bottomStart: CornerSize | ||||
|         bottomStart: CornerSize, | ||||
|     ) = RoundedCornerShape( | ||||
|         topStart = topStart, | ||||
|         topEnd = topEnd, | ||||
|  | ||||
| @ -9,7 +9,7 @@ import androidx.compose.ui.Modifier | ||||
| import androidx.compose.ui.layout.onGloballyPositioned | ||||
| import androidx.compose.ui.unit.IntSize | ||||
| import com.caverock.androidsvg.SVG | ||||
| import me.ash.reader.data.preference.LocalDarkTheme | ||||
| import me.ash.reader.data.model.preference.LocalDarkTheme | ||||
| import me.ash.reader.ui.svg.parseDynamicColor | ||||
| import me.ash.reader.ui.theme.palette.LocalTonalPalettes | ||||
| 
 | ||||
|  | ||||
| @ -4,7 +4,7 @@ import androidx.compose.runtime.Composable | ||||
| @Composable | ||||
| fun RYExtensibleVisibility( | ||||
|     visible: Boolean, | ||||
|     content: @Composable AnimatedVisibilityScope.() -> Unit | ||||
|     content: @Composable AnimatedVisibilityScope.() -> Unit, | ||||
| ) { | ||||
|     AnimatedVisibility( | ||||
|         visible = visible, | ||||
|  | ||||
| @ -35,7 +35,7 @@ fun RYSwitch( | ||||
|     modifier: Modifier = Modifier, | ||||
|     activated: Boolean, | ||||
|     enable: Boolean = true, | ||||
|     onClick: (() -> Unit)? = null | ||||
|     onClick: (() -> Unit)? = null, | ||||
| ) { | ||||
|     val tonalPalettes = LocalTonalPalettes.current | ||||
| 
 | ||||
| @ -74,7 +74,7 @@ fun SwitchHeadline( | ||||
|     activated: Boolean, | ||||
|     onClick: () -> Unit, | ||||
|     title: String, | ||||
|     modifier: Modifier = Modifier | ||||
|     modifier: Modifier = Modifier, | ||||
| ) { | ||||
|     val tonalPalettes = LocalTonalPalettes.current | ||||
| 
 | ||||
|  | ||||
| @ -21,7 +21,7 @@ fun Tips( | ||||
|     Column( | ||||
|         modifier = modifier | ||||
|             .fillMaxWidth() | ||||
|             .padding(horizontal =  24.dp, vertical = 16.dp), | ||||
|             .padding(horizontal = 24.dp, vertical = 16.dp), | ||||
|     ) { | ||||
|         Icon( | ||||
|             imageVector = Icons.Outlined.Info, | ||||
|  | ||||
| @ -18,7 +18,7 @@ const val INJECTION_TOKEN = "/android_asset_font/" | ||||
| fun WebView( | ||||
|     modifier: Modifier = Modifier, | ||||
|     content: String, | ||||
|     onReceivedError: (error: WebResourceError?) -> Unit = {} | ||||
|     onReceivedError: (error: WebResourceError?) -> Unit = {}, | ||||
| ) { | ||||
|     val context = LocalContext.current | ||||
|     val color = MaterialTheme.colorScheme.onSurfaceVariant.toArgb() | ||||
| @ -28,7 +28,7 @@ fun WebView( | ||||
| 
 | ||||
|             override fun shouldInterceptRequest( | ||||
|                 view: WebView?, | ||||
|                 url: String? | ||||
|                 url: String?, | ||||
|             ): WebResourceResponse? { | ||||
|                 if (url != null && url.contains(INJECTION_TOKEN)) { | ||||
|                     try { | ||||
| @ -63,7 +63,7 @@ fun WebView( | ||||
| 
 | ||||
|             override fun shouldOverrideUrlLoading( | ||||
|                 view: WebView?, | ||||
|                 request: WebResourceRequest? | ||||
|                 request: WebResourceRequest?, | ||||
|             ): Boolean { | ||||
|                 if (null == request?.url) return false | ||||
|                 val url = request.url.toString() | ||||
| @ -79,7 +79,7 @@ fun WebView( | ||||
|             override fun onReceivedError( | ||||
|                 view: WebView?, | ||||
|                 request: WebResourceRequest?, | ||||
|                 error: WebResourceError? | ||||
|                 error: WebResourceError?, | ||||
|             ) { | ||||
|                 super.onReceivedError(view, request, error) | ||||
|                 onReceivedError(error) | ||||
| @ -88,7 +88,7 @@ fun WebView( | ||||
|             override fun onReceivedSslError( | ||||
|                 view: WebView?, | ||||
|                 handler: SslErrorHandler?, | ||||
|                 error: SslError? | ||||
|                 error: SslError?, | ||||
|             ) { | ||||
|                 handler?.cancel() | ||||
|             } | ||||
|  | ||||
| @ -25,6 +25,7 @@ import androidx.compose.ui.text.AnnotatedString | ||||
| import androidx.compose.ui.text.SpanStyle | ||||
| 
 | ||||
| class AnnotatedParagraphStringBuilder { | ||||
| 
 | ||||
|     // Private for a reason | ||||
|     private val builder: AnnotatedString.Builder = AnnotatedString.Builder() | ||||
| 
 | ||||
| @ -60,7 +61,7 @@ class AnnotatedParagraphStringBuilder { | ||||
|         builder.pushStringAnnotation(tag = tag, annotation = annotation) | ||||
| 
 | ||||
|     fun pushComposableStyle( | ||||
|         style: @Composable () -> SpanStyle | ||||
|         style: @Composable () -> SpanStyle, | ||||
|     ): Int { | ||||
|         composableStyles.add( | ||||
|             ComposableStyleWithStartEnd( | ||||
| @ -72,7 +73,7 @@ class AnnotatedParagraphStringBuilder { | ||||
|     } | ||||
| 
 | ||||
|     fun popComposableStyle( | ||||
|         index: Int | ||||
|         index: Int, | ||||
|     ) { | ||||
|         poppedComposableStyles.add( | ||||
|             composableStyles.removeAt(index).copy(end = builder.length) | ||||
| @ -122,20 +123,25 @@ fun AnnotatedParagraphStringBuilder.ensureDoubleNewline() { | ||||
|         lastTwoChars.isEmpty() -> { | ||||
|             // Nothing to do | ||||
|         } | ||||
| 
 | ||||
|         length == 1 && lastTwoChars.peekLatest()?.isWhitespace() == true -> { | ||||
|             // Nothing to do | ||||
|         } | ||||
| 
 | ||||
|         length == 2 && | ||||
|                 lastTwoChars.peekLatest()?.isWhitespace() == true && | ||||
|                 lastTwoChars.peekSecondLatest()?.isWhitespace() == true -> { | ||||
|             // Nothing to do | ||||
|         } | ||||
| 
 | ||||
|         lastTwoChars.peekLatest() == '\n' && lastTwoChars.peekSecondLatest() == '\n' -> { | ||||
|             // Nothing to do | ||||
|         } | ||||
| 
 | ||||
|         lastTwoChars.peekLatest() == '\n' -> { | ||||
|             append('\n') | ||||
|         } | ||||
| 
 | ||||
|         else -> { | ||||
|             append("\n\n") | ||||
|         } | ||||
| @ -147,12 +153,15 @@ private fun AnnotatedParagraphStringBuilder.ensureSingleNewline() { | ||||
|         lastTwoChars.isEmpty() -> { | ||||
|             // Nothing to do | ||||
|         } | ||||
| 
 | ||||
|         length == 1 && lastTwoChars.peekLatest()?.isWhitespace() == true -> { | ||||
|             // Nothing to do | ||||
|         } | ||||
| 
 | ||||
|         lastTwoChars.peekLatest() == '\n' -> { | ||||
|             // Nothing to do | ||||
|         } | ||||
| 
 | ||||
|         else -> { | ||||
|             append('\n') | ||||
|         } | ||||
| @ -187,5 +196,5 @@ private fun <T> List<T>.peekSecondLatest(): T? { | ||||
| data class ComposableStyleWithStartEnd( | ||||
|     val style: @Composable () -> SpanStyle, | ||||
|     val start: Int, | ||||
|     val end: Int = -1 | ||||
|     val end: Int = -1, | ||||
| ) | ||||
|  | ||||
| @ -206,6 +206,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
| 
 | ||||
|             is Element -> { | ||||
|                 val element = node | ||||
|                 when (element.tagName()) { | ||||
| @ -232,6 +233,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "br" -> append('\n') | ||||
|                     "h1" -> { | ||||
|                         withParagraph { | ||||
| @ -242,6 +244,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "h2" -> { | ||||
|                         withParagraph { | ||||
|                             withComposableStyle( | ||||
| @ -251,6 +254,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "h3" -> { | ||||
|                         withParagraph { | ||||
|                             withComposableStyle( | ||||
| @ -260,6 +264,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "h4" -> { | ||||
|                         withParagraph { | ||||
|                             withComposableStyle( | ||||
| @ -269,6 +274,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "h5" -> { | ||||
|                         withParagraph { | ||||
|                             withComposableStyle( | ||||
| @ -278,6 +284,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "h6" -> { | ||||
|                         withParagraph { | ||||
|                             withComposableStyle( | ||||
| @ -287,6 +294,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "strong", "b" -> { | ||||
|                         withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { | ||||
|                             appendTextChildren( | ||||
| @ -298,6 +306,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "i", "em", "cite", "dfn" -> { | ||||
|                         withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { | ||||
|                             appendTextChildren( | ||||
| @ -309,6 +318,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "tt" -> { | ||||
|                         withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) { | ||||
|                             appendTextChildren( | ||||
| @ -320,6 +330,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "u" -> { | ||||
|                         withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) { | ||||
|                             appendTextChildren( | ||||
| @ -331,6 +342,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "sup" -> { | ||||
|                         withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) { | ||||
|                             appendTextChildren( | ||||
| @ -342,6 +354,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "sub" -> { | ||||
|                         withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) { | ||||
|                             appendTextChildren( | ||||
| @ -353,6 +366,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "font" -> { | ||||
|                         val fontFamily: FontFamily? = element.attr("face")?.asFontFamily() | ||||
|                         withStyle(SpanStyle(fontFamily = fontFamily)) { | ||||
| @ -365,6 +379,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             ) | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "pre" -> { | ||||
|                         appendTextChildren( | ||||
|                             element.childNodes(), | ||||
| @ -375,6 +390,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             baseUrl = baseUrl, | ||||
|                         ) | ||||
|                     } | ||||
| 
 | ||||
|                     "code" -> { | ||||
|                         if (element.parent()?.tagName() == "pre") { | ||||
|                             terminateCurrentText() | ||||
| @ -400,6 +416,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "blockquote" -> { | ||||
|                         withParagraph { | ||||
|                             withComposableStyle( | ||||
| @ -415,6 +432,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "a" -> { | ||||
|                         withComposableStyle( | ||||
|                             style = { linkTextStyle().toSpanStyle() } | ||||
| @ -430,6 +448,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "img" -> { | ||||
|                         val imageCandidates = getImageSource(baseUrl, element) | ||||
|                         if (imageCandidates.hasImage) { | ||||
| @ -502,6 +521,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "ul" -> { | ||||
|                         element.children() | ||||
|                             .filter { it.tagName() == "li" } | ||||
| @ -519,6 +539,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                                 } | ||||
|                             } | ||||
|                     } | ||||
| 
 | ||||
|                     "ol" -> { | ||||
|                         element.children() | ||||
|                             .filter { it.tagName() == "li" } | ||||
| @ -536,6 +557,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                                 } | ||||
|                             } | ||||
|                     } | ||||
| 
 | ||||
|                     "table" -> { | ||||
|                         appendTable { | ||||
|                             /* | ||||
| @ -581,6 +603,7 @@ private fun TextComposer.appendTextChildren( | ||||
|                             append("\n\n") | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "iframe" -> { | ||||
|                         val video: Video? = getVideo(element.attr("abs:src")) | ||||
| 
 | ||||
| @ -629,9 +652,11 @@ private fun TextComposer.appendTextChildren( | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
| 
 | ||||
|                     "video" -> { | ||||
|                         // not implemented yet. remember to disable selection | ||||
|                     } | ||||
| 
 | ||||
|                     else -> { | ||||
|                         appendTextChildren( | ||||
|                             nodes = element.childNodes(), | ||||
| @ -707,8 +732,9 @@ internal fun getImageSource(baseUrl: String, element: Element) = ImageCandidates | ||||
| internal class ImageCandidates( | ||||
|     val baseUrl: String, | ||||
|     val srcSet: String, | ||||
|     val absSrc: String | ||||
|     val absSrc: String, | ||||
| ) { | ||||
| 
 | ||||
|     val hasImage: Boolean = srcSet.isNotBlank() || absSrc.isNotBlank() | ||||
| 
 | ||||
|     /** | ||||
| @ -728,9 +754,11 @@ internal class ImageCandidates( | ||||
|                         descriptor.endsWith("w", ignoreCase = true) -> { | ||||
|                             descriptor.substringBefore("w").toFloat() / maxSize.width.pxOrElse { 1 } | ||||
|                         } | ||||
| 
 | ||||
|                         descriptor.endsWith("x", ignoreCase = true) -> { | ||||
|                             descriptor.substringBefore("x").toFloat() / pixelDensity | ||||
|                         } | ||||
| 
 | ||||
|                         else -> { | ||||
|                             return@fold acc | ||||
|                         } | ||||
|  | ||||
| @ -24,8 +24,9 @@ import androidx.compose.runtime.Composable | ||||
| import androidx.compose.ui.text.SpanStyle | ||||
| 
 | ||||
| class TextComposer( | ||||
|     val paragraphEmitter: (AnnotatedParagraphStringBuilder) -> Unit | ||||
|     val paragraphEmitter: (AnnotatedParagraphStringBuilder) -> Unit, | ||||
| ) { | ||||
| 
 | ||||
|     val spanStack: MutableList<Span> = mutableListOf() | ||||
| 
 | ||||
|     // The identity of this will change - do not reference it in blocks | ||||
| @ -48,6 +49,7 @@ class TextComposer( | ||||
|                     tag = span.tag, | ||||
|                     annotation = span.annotation | ||||
|                 ) | ||||
| 
 | ||||
|                 is SpanWithComposableStyle -> builder.pushComposableStyle(span.spanStyle) | ||||
|             } | ||||
|         } | ||||
| @ -75,8 +77,8 @@ class TextComposer( | ||||
|         link: String? = null, | ||||
|         onLinkClick: (String) -> Unit, | ||||
|         block: ( | ||||
|             onClick: (() -> Unit)? | ||||
|         ) -> R | ||||
|             onClick: (() -> Unit)?, | ||||
|         ) -> R, | ||||
|     ): R { | ||||
|         val url = link ?: findClosestLink() | ||||
|         //builder.ensureDoubleNewline() | ||||
| @ -117,7 +119,7 @@ class TextComposer( | ||||
| } | ||||
| 
 | ||||
| inline fun <R : Any> TextComposer.withParagraph( | ||||
|     crossinline block: TextComposer.() -> R | ||||
|     crossinline block: TextComposer.() -> R, | ||||
| ): R { | ||||
|     ensureDoubleNewline() | ||||
|     return block(this) | ||||
| @ -125,7 +127,7 @@ inline fun <R : Any> TextComposer.withParagraph( | ||||
| 
 | ||||
| inline fun <R : Any> TextComposer.withStyle( | ||||
|     style: SpanStyle, | ||||
|     crossinline block: TextComposer.() -> R | ||||
|     crossinline block: TextComposer.() -> R, | ||||
| ): R { | ||||
|     spanStack.add(SpanWithStyle(style)) | ||||
|     val index = pushStyle(style) | ||||
| @ -139,7 +141,7 @@ inline fun <R : Any> TextComposer.withStyle( | ||||
| 
 | ||||
| inline fun <R : Any> TextComposer.withComposableStyle( | ||||
|     noinline style: @Composable () -> SpanStyle, | ||||
|     crossinline block: TextComposer.() -> R | ||||
|     crossinline block: TextComposer.() -> R, | ||||
| ): R { | ||||
|     spanStack.add(SpanWithComposableStyle(style)) | ||||
|     val index = pushComposableStyle(style) | ||||
| @ -154,7 +156,7 @@ inline fun <R : Any> TextComposer.withComposableStyle( | ||||
| inline fun <R : Any> TextComposer.withAnnotation( | ||||
|     tag: String, | ||||
|     annotation: String, | ||||
|     crossinline block: TextComposer.() -> R | ||||
|     crossinline block: TextComposer.() -> R, | ||||
| ): R { | ||||
|     spanStack.add(SpanWithAnnotation(tag = tag, annotation = annotation)) | ||||
|     val index = pushStringAnnotation(tag = tag, annotation = annotation) | ||||
| @ -169,14 +171,14 @@ inline fun <R : Any> TextComposer.withAnnotation( | ||||
| sealed class Span | ||||
| 
 | ||||
| data class SpanWithStyle( | ||||
|     val spanStyle: SpanStyle | ||||
|     val spanStyle: SpanStyle, | ||||
| ) : Span() | ||||
| 
 | ||||
| data class SpanWithAnnotation( | ||||
|     val tag: String, | ||||
|     val annotation: String | ||||
|     val annotation: String, | ||||
| ) : Span() | ||||
| 
 | ||||
| data class SpanWithComposableStyle( | ||||
|     val spanStyle: @Composable () -> SpanStyle | ||||
|     val spanStyle: @Composable () -> SpanStyle, | ||||
| ) : Span() | ||||
|  | ||||
| @ -44,8 +44,9 @@ data class Video( | ||||
|     val src: String, | ||||
|     val imageUrl: String, | ||||
|     // Youtube needs a different link than embed links | ||||
|     val link: String | ||||
|     val link: String, | ||||
| ) { | ||||
| 
 | ||||
|     val width: Int | ||||
|         get() = 480 | ||||
| 
 | ||||
|  | ||||
Some files were not shown because too many files have changed in this diff Show More
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user