├── README.md ├── app ├── .gitignore ├── src │ ├── main │ │ ├── ic_launcher-web.png │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_foreground.png │ │ │ ├── values │ │ │ │ ├── ic_launcher_background.xml │ │ │ │ ├── themes.xml │ │ │ │ ├── colors.xml │ │ │ │ └── strings.xml │ │ │ ├── drawable │ │ │ │ ├── ic_shortcut_background.xml │ │ │ │ ├── ic_shortcut_search.xml │ │ │ │ ├── ic_search.xml │ │ │ │ ├── ic_refresh.xml │ │ │ │ ├── ic_default_avatar.xml │ │ │ │ ├── wifi_error.xml │ │ │ │ └── empty.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ └── ic_launcher.xml │ │ │ ├── values-night │ │ │ │ ├── themes.xml │ │ │ │ └── colors.xml │ │ │ ├── xml │ │ │ │ └── shortcuts.xml │ │ │ └── values-zh │ │ │ │ └── strings.xml │ │ ├── java │ │ │ └── io │ │ │ │ └── github │ │ │ │ └── bkmioa │ │ │ │ └── nexusrss │ │ │ │ ├── model │ │ │ │ ├── MemberRequestBody.kt │ │ │ │ ├── Result.kt │ │ │ │ ├── ItemList.kt │ │ │ │ ├── MemberInfo.kt │ │ │ │ ├── CommentRequestBody.kt │ │ │ │ ├── Release.kt │ │ │ │ ├── FileItem.kt │ │ │ │ ├── Status.kt │ │ │ │ ├── Comment.kt │ │ │ │ ├── DownloadNodeModel.kt │ │ │ │ ├── Tab.kt │ │ │ │ ├── RequestData.kt │ │ │ │ └── Item.kt │ │ │ │ ├── util │ │ │ │ ├── RunIf.kt │ │ │ │ └── compose │ │ │ │ │ └── RunIf.kt │ │ │ │ ├── repository │ │ │ │ ├── UserAgent.kt │ │ │ │ ├── GithubService.kt │ │ │ │ ├── UserAgentInterceptor.kt │ │ │ │ └── MtService.kt │ │ │ │ ├── detail │ │ │ │ ├── DetailArgs.kt │ │ │ │ ├── FileNode.kt │ │ │ │ ├── RichContent.kt │ │ │ │ ├── AlternativeVersions.kt │ │ │ │ └── DetailViewModel.kt │ │ │ │ ├── download │ │ │ │ ├── DownloadTask.kt │ │ │ │ ├── DownloadNode.kt │ │ │ │ ├── DownloadReceiver.kt │ │ │ │ ├── list │ │ │ │ │ ├── DownloadSettingsViewModel.kt │ │ │ │ │ └── DownloadSettingsScreen.kt │ │ │ │ ├── QBittorrentNode.kt │ │ │ │ ├── UTorrentNode.kt │ │ │ │ ├── RemoteDownloader.kt │ │ │ │ ├── TransmissionNode.kt │ │ │ │ └── edit │ │ │ │ │ └── EditDownloadNodeViewModel.kt │ │ │ │ ├── db │ │ │ │ ├── SetConverter.kt │ │ │ │ ├── StringArrayConverter.kt │ │ │ │ ├── AppDao.kt │ │ │ │ ├── DownloadDao.kt │ │ │ │ └── AppDatabase.kt │ │ │ │ ├── widget │ │ │ │ ├── NoNestedScrollConnection.kt │ │ │ │ ├── Toaster.kt │ │ │ │ ├── PullToRefreshBox.kt │ │ │ │ ├── SlantedRightPartShape.kt │ │ │ │ ├── LifecycleAware.kt │ │ │ │ ├── Shadow.kt │ │ │ │ ├── Empty.kt │ │ │ │ └── SearchBar.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── home │ │ │ │ └── HomeViewModel.kt │ │ │ │ ├── Settings.kt │ │ │ │ ├── theme │ │ │ │ ├── AppTheme.kt │ │ │ │ ├── PaletteTokens.kt │ │ │ │ └── TonalPalette.kt │ │ │ │ ├── search │ │ │ │ ├── SearchHistoryStore.kt │ │ │ │ └── SearchViewModel.kt │ │ │ │ ├── cookie │ │ │ │ └── SharedCookieJar.kt │ │ │ │ ├── SharedTransitionScope.kt │ │ │ │ ├── bbcode │ │ │ │ └── BbNodes.kt │ │ │ │ ├── App.kt │ │ │ │ ├── webview │ │ │ │ ├── WebImageLoader.kt │ │ │ │ └── CacheResponse.kt │ │ │ │ ├── tabs │ │ │ │ ├── TabsViewModel.kt │ │ │ │ ├── EditTabViewModel.kt │ │ │ │ └── EditTabScreen.kt │ │ │ │ ├── base │ │ │ │ └── Pager.kt │ │ │ │ ├── checkversion │ │ │ │ └── CheckVersionViewModel.kt │ │ │ │ ├── list │ │ │ │ └── ListViewModel.kt │ │ │ │ ├── AppNavHost.kt │ │ │ │ ├── comment │ │ │ │ ├── CommentViewModel.kt │ │ │ │ └── CommentList.kt │ │ │ │ ├── di │ │ │ │ └── AppModule.kt │ │ │ │ └── option │ │ │ │ ├── OptionViewModel.kt │ │ │ │ └── OptionsUI.kt │ │ └── AndroidManifest.xml │ └── test │ │ └── java │ │ └── io │ │ └── github │ │ └── bkmioa │ │ └── nexusrss │ │ └── ExampleUnitTest.kt ├── proguard-rules.pro ├── build.gradle.kts └── schemas │ └── io.github.bkmioa.nexusrss.db.AppDatabase │ └── 6.json ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── getVersion.gradle └── libs.versions.toml ├── .gitignore ├── settings.gradle.kts ├── gradle.properties ├── .github └── workflows │ └── android.yml ├── gradlew.bat └── gradlew /README.md: -------------------------------------------------------------------------------- 1 | # NexusRss -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /app/src/main/ic_launcher-web.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/ic_launcher-web.png -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | .DS_Store 5 | /build 6 | /captures 7 | .externalNativeBuild 8 | .idea/ 9 | .kotlin/ 10 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bkmioa/NexusRss/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/MemberRequestBody.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | class MemberRequestBody( 4 | val ids: List 5 | ) -------------------------------------------------------------------------------- /app/src/main/res/values/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #F6CA60 4 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/Result.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | class Result { 4 | var code: Int = 0 5 | 6 | var message: String = "" 7 | 8 | var data: T? = null 9 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/util/RunIf.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.util 2 | 3 | inline fun T.runIf(predicate: Boolean, block: T.() -> T): T = if (predicate) { 4 | block() 5 | } else { 6 | this 7 | } -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Aug 19 15:47:52 CST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip 7 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/repository/UserAgent.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.repository 2 | 3 | import android.webkit.WebSettings 4 | import io.github.bkmioa.nexusrss.App 5 | 6 | 7 | object UserAgent { 8 | val userAgentString = WebSettings.getDefaultUserAgent(App.instance) 9 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/ItemList.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | class ItemList { 4 | var pageNumber: Int = 0 5 | 6 | var pageSize: Int = 0 7 | 8 | var total: Int = 0 9 | 10 | var totalPages: Int = 0 11 | 12 | var data: List = emptyList() 13 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_shortcut_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/util/compose/RunIf.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.util.compose 2 | 3 | import androidx.compose.runtime.Composable 4 | 5 | @Composable 6 | inline fun T.runIf(predicate: Boolean, block: @Composable T.() -> T): T = if (predicate) { 7 | block() 8 | } else { 9 | this 10 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/detail/DetailArgs.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.detail 2 | 3 | import android.os.Parcelable 4 | import io.github.bkmioa.nexusrss.model.Item 5 | import kotlinx.parcelize.Parcelize 6 | 7 | @Parcelize 8 | data class DetailArgs( 9 | val id: String, 10 | val data: Item? 11 | ) : Parcelable 12 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/MemberInfo.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | 4 | data class MemberInfo( 5 | val username: String? = null, 6 | val avatarUrl: String? = null, 7 | val uid: String? = null, 8 | ) { 9 | companion object { 10 | val Empty: MemberInfo = MemberInfo() 11 | } 12 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/download/DownloadTask.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.download 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | @Parcelize 7 | data class DownloadTask( 8 | val downloadNode: DownloadNode, 9 | val torrentUrl: String, 10 | val path: String? 11 | ) : Parcelable { 12 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/CommentRequestBody.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | import androidx.annotation.IntRange 4 | 5 | 6 | data class CommentRequestBody( 7 | val relationId: String, 8 | 9 | val type: String = "TORRENT", 10 | 11 | @IntRange(from = 1) 12 | val pageNumber: Int, 13 | 14 | val pageSize: Int = 20, 15 | ) -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_shortcut_search.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | gradlePluginPortal() 6 | } 7 | } 8 | dependencyResolutionManagement { 9 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) 10 | repositories { 11 | google() 12 | mavenCentral() 13 | } 14 | } 15 | 16 | rootProject.name = "NexusRss" 17 | include(":app") 18 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/db/SetConverter.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.db 2 | 3 | import androidx.room.TypeConverter 4 | 5 | class SetConverter { 6 | @TypeConverter 7 | fun toString(set: Set) = set.joinToString(",") 8 | 9 | @TypeConverter 10 | fun toList(str: String): Set = if (str.isEmpty()) { 11 | emptySet() 12 | } else { 13 | str.split(",").toSet() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/Release.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | import java.util.Date 4 | 5 | 6 | class Release { 7 | var id: Int = 0 8 | var name: String? = null 9 | var tagName: String? = null 10 | var body: String? = null 11 | var htmlUrl: String? = null 12 | var assetsUrl: String? = null 13 | var prerelease: Boolean = false 14 | var publishedAt: Date? = null 15 | } 16 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/repository/GithubService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.repository 2 | 3 | import io.github.bkmioa.nexusrss.model.Release 4 | import retrofit2.http.GET 5 | import retrofit2.http.Headers 6 | 7 | interface GithubService { 8 | @GET("/repos/bkmioa/NexusRss/releases?per_page=1") 9 | @Headers("Accept: application/vnd.github+json", "X-GitHub-Api-Version: 2022-11-28") 10 | suspend fun releaseList(): Array 11 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/FileItem.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | import android.text.format.Formatter 4 | import io.github.bkmioa.nexusrss.App 5 | 6 | class FileItem { 7 | var id: String = "" 8 | 9 | //var torrent: String = "" 10 | 11 | //var createdDate: String = "" 12 | 13 | var name: String = "" 14 | 15 | var size: Long = 0 16 | 17 | val sizeText: String 18 | get() = Formatter.formatShortFileSize(App.instance, size) 19 | } -------------------------------------------------------------------------------- /app/src/main/res/values/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/download/DownloadNode.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.download 2 | 3 | import android.os.Parcelable 4 | import io.reactivex.Single 5 | 6 | interface DownloadNode : Parcelable { 7 | val host: String 8 | val userName: String 9 | val password: String 10 | val defaultPath: String? 11 | 12 | /** 13 | * @return success with string or error with error message 14 | */ 15 | fun download(torrentUrl: String, path: String? = null): Single 16 | } -------------------------------------------------------------------------------- /app/src/main/res/values-night/themes.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/test/java/io/github/bkmioa/nexusrss/ExampleUnitTest.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Test 5 | 6 | /** 7 | * Example local unit test, which will execute on the development machine (host). 8 | 9 | * @see [Testing documentation](http://d.android.com/tools/testing) 10 | */ 11 | class ExampleUnitTest { 12 | @Test 13 | @Throws(Exception::class) 14 | fun addition_isCorrect() { 15 | assertEquals(4, (2 + 2).toLong()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/db/StringArrayConverter.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.db 2 | 3 | import androidx.room.TypeConverter 4 | 5 | class StringArrayConverter { 6 | @TypeConverter 7 | fun toString(array: Array) = array.sortedArray().joinToString(",") 8 | 9 | @TypeConverter 10 | fun toArray(str: String): Array{ 11 | return if(str.isEmpty()){ 12 | emptyArray() 13 | }else{ 14 | str.split(",").toTypedArray().sortedArray() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/repository/UserAgentInterceptor.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.repository 2 | 3 | import android.app.Application 4 | import android.webkit.WebView 5 | import okhttp3.Interceptor 6 | import okhttp3.Response 7 | 8 | 9 | class UserAgentInterceptor(app: Application) : Interceptor { 10 | override fun intercept(chain: Interceptor.Chain): Response { 11 | val newRequest = chain.request().newBuilder().header("User-Agent", UserAgent.userAgentString).build() 12 | return chain.proceed(newRequest) 13 | } 14 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_search.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_refresh.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/Status.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | import android.os.Parcelable 4 | import kotlinx.parcelize.Parcelize 5 | 6 | @Parcelize 7 | class Status( 8 | var id: String = "", 9 | 10 | var toppingLevel: Int = 0, 11 | 12 | var toppingEndTime: String? = null, 13 | 14 | var seeders: String = "", 15 | 16 | var leechers: String = "", 17 | 18 | var comments: String = "", 19 | ) : Parcelable { 20 | companion object { 21 | val DEFAULT = Status() 22 | } 23 | 24 | val isTopped: Boolean 25 | get() = toppingLevel != 0 26 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/widget/NoNestedScrollConnection.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.widget 2 | 3 | import androidx.compose.ui.geometry.Offset 4 | import androidx.compose.ui.input.nestedscroll.NestedScrollConnection 5 | import androidx.compose.ui.input.nestedscroll.NestedScrollSource 6 | 7 | class NoNestedScrollConnection : NestedScrollConnection { 8 | override fun onPreScroll(available: Offset, source: NestedScrollSource) = available.copy(x = 0f) 9 | override fun onPostScroll( 10 | consumed: Offset, 11 | available: Offset, 12 | source: NestedScrollSource 13 | ) = available.copy(x = 0f) 14 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss 2 | 3 | import android.os.Bundle 4 | import androidx.activity.ComponentActivity 5 | import androidx.activity.compose.setContent 6 | import androidx.core.view.WindowCompat 7 | import io.github.bkmioa.nexusrss.theme.AppTheme 8 | 9 | 10 | class MainActivity : ComponentActivity() { 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | WindowCompat.setDecorFitsSystemWindows(window, false) 14 | 15 | setContent { 16 | AppTheme { 17 | AppNavHost() 18 | } 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/Comment.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | import android.text.format.DateUtils 4 | import java.text.SimpleDateFormat 5 | import java.util.Date 6 | import java.util.Locale 7 | 8 | 9 | data class Comment( 10 | val id: String, 11 | val createdDate: String, 12 | val author: String, 13 | val text: String, 14 | ) { 15 | @Transient 16 | var member: MemberInfo? = null 17 | 18 | fun getDateText(): String { 19 | try { 20 | val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US).parse(createdDate) 21 | return DateUtils.getRelativeTimeSpanString(date.time).toString() 22 | } catch (e: Exception) { 23 | return "" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /gradle/getVersion.gradle: -------------------------------------------------------------------------------- 1 | ext.generateVersionCode = { -> 2 | try { 3 | def stdout = new ByteArrayOutputStream() 4 | exec { 5 | commandLine 'git', 'rev-list', '--first-parent', '--count', 'HEAD' 6 | standardOutput = stdout 7 | } 8 | return Integer.parseInt(stdout.toString().trim()) 9 | } 10 | catch (ignored) { 11 | return -1; 12 | } 13 | } 14 | 15 | ext.generateVersionName = { -> 16 | try { 17 | def stdout = new ByteArrayOutputStream() 18 | exec { 19 | commandLine 'git', 'describe', '--tags', '--dirty' 20 | standardOutput = stdout 21 | } 22 | return stdout.toString().trim() 23 | } 24 | catch (ignored) { 25 | return null; 26 | } 27 | } -------------------------------------------------------------------------------- /app/src/main/res/xml/shortcuts.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 11 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/home/HomeViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.home 2 | 3 | import com.airbnb.mvrx.MavericksState 4 | import com.airbnb.mvrx.MavericksViewModel 5 | import io.github.bkmioa.nexusrss.db.AppDatabase 6 | import io.github.bkmioa.nexusrss.model.Tab 7 | import org.koin.core.component.KoinComponent 8 | import org.koin.core.component.inject 9 | 10 | data class UiState( 11 | val tabs: List = emptyList() 12 | ) : MavericksState 13 | 14 | class HomeViewModel(initialState: UiState) : MavericksViewModel(initialState), KoinComponent { 15 | private val appDatabase: AppDatabase by inject() 16 | 17 | private val appDao = appDatabase.appDao() 18 | 19 | init { 20 | appDao.getActivateTabs() 21 | .setOnEach { 22 | copy(tabs = it) 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | 3 | # IDE (e.g. Android Studio) users: 4 | # Gradle settings configured through the IDE *will override* 5 | # any settings specified in this file. 6 | 7 | # For more details on how to configure your build environment visit 8 | # http://www.gradle.org/docs/current/userguide/build_environment.html 9 | 10 | # Specifies the JVM arguments used for the daemon process. 11 | # The setting is particularly useful for tweaking memory settings. 12 | 13 | # When configured, Gradle will run in incubating parallel mode. 14 | # This option should only be used with decoupled projects. More details, visit 15 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 16 | # org.gradle.parallel=true 17 | 18 | org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8 19 | 20 | android.useAndroidX=true 21 | android.enableJetifier=false 22 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/db/AppDao.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.db 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.* 5 | import io.github.bkmioa.nexusrss.model.Tab 6 | import io.reactivex.Single 7 | import kotlinx.coroutines.flow.Flow 8 | 9 | @Dao 10 | interface AppDao { 11 | @Query("SELECT * FROM tab") 12 | fun getAllTab(): LiveData> 13 | 14 | @Insert(onConflict = OnConflictStrategy.REPLACE) 15 | fun addTab(vararg tabs: Tab): Array 16 | 17 | @Insert(onConflict = OnConflictStrategy.REPLACE) 18 | fun updateTab(vararg tabs: Tab): Array 19 | 20 | @Delete 21 | fun deleteTab(tab: Tab): Int 22 | 23 | @Query("SELECT * FROM tab where isShow = 1 order by `order`") 24 | fun getActivateTabs(): Flow> 25 | 26 | @Query("SELECT * FROM tab order by `order`") 27 | fun getAllTabFlow(): Flow> 28 | 29 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/Settings.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss 2 | 3 | import com.chibatching.kotpref.KotprefModel 4 | 5 | class Settings { 6 | companion object : KotprefModel() { 7 | 8 | override val kotprefName = BuildConfig.APPLICATION_ID + "_preferences" 9 | 10 | const val DEFAULT_BASE_URL = "https://kp.m-team.cc" 11 | const val DEFAULT_API_URL = "https://api.m-team.cc" 12 | 13 | var BASE_URL by stringPref(DEFAULT_BASE_URL, key = "baseUrl") 14 | var API_URL by stringPref(DEFAULT_API_URL, key = "apiUrl") 15 | val LOGIN_URL 16 | get() = "$BASE_URL/login" 17 | var API_KEY by stringPref(key = "apiKey") 18 | var PAGE_SIZE by intPref(50, "pageSize") 19 | var REMOTE_URL by stringPref("http://localhost", key = "remoteUrl") 20 | var REMOTE_USERNAME by stringPref(key = "remoteUsername") 21 | var REMOTE_PASSWORD by stringPref(key = "remotePassword") 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/db/DownloadDao.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.db 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.Dao 5 | import androidx.room.Delete 6 | import androidx.room.Insert 7 | import androidx.room.OnConflictStrategy 8 | import androidx.room.Query 9 | import io.github.bkmioa.nexusrss.model.DownloadNodeModel 10 | import kotlinx.coroutines.flow.Flow 11 | 12 | @Dao 13 | interface DownloadDao { 14 | @Insert(onConflict = OnConflictStrategy.REPLACE) 15 | fun addOrUpdateNode(node: DownloadNodeModel) 16 | 17 | @Query("select * from download_node where id = :id") 18 | suspend fun getOne(id: Long): DownloadNodeModel 19 | 20 | @Query("select * from download_node") 21 | fun getAll(): List 22 | 23 | @Query("select * from download_node") 24 | fun getAllLiveData(): LiveData> 25 | 26 | @Query("select * from download_node") 27 | fun getAllFlow(): Flow> 28 | 29 | @Delete 30 | fun delete(node: DownloadNodeModel) 31 | } -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | #1E88E5 3 | #FFFFFF 4 | #5E35B1 5 | #FFFFFF 6 | #FFB300 7 | #000000 8 | #D81B60 9 | #FFFFFF 10 | #00ACC1 11 | #FFFFFF 12 | #66BB6A 13 | #FFFFFF 14 | #EF5350 15 | #FFFFFF 16 | #EEEEEE 17 | #000000 18 | #2DBA63 19 | #FFFFFF 20 | #F5C518 21 | #000000 22 | -------------------------------------------------------------------------------- /app/src/main/res/values-night/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | #1565C0 3 | #FFFFFF 4 | #4527A0 5 | #FFFFFF 6 | #FF8F00 7 | #000000 8 | #AD1457 9 | #FFFFFF 10 | #00838F 11 | #FFFFFF 12 | #43A047 13 | #FFFFFF 14 | #E53935 15 | #FFFFFF 16 | #37474F 17 | #FFFFFF 18 | #1E8C4A 19 | #FFFFFF 20 | #D4A507 21 | #000000 22 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/widget/Toaster.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.widget 2 | 3 | import android.widget.Toast 4 | import androidx.compose.runtime.Composable 5 | import androidx.compose.runtime.LaunchedEffect 6 | import androidx.compose.ui.platform.LocalContext 7 | 8 | fun CharSequence?.asToastMessage(duration: Int = Toast.LENGTH_SHORT): ToastMessage { 9 | return ToastMessage(this, duration) 10 | } 11 | 12 | class ToastMessage( 13 | val text: CharSequence? = null, 14 | val resId: Int? = null, 15 | val duration: Int = Toast.LENGTH_SHORT 16 | ) { 17 | fun isEmpty(): Boolean { 18 | return text == null && (resId == null || resId == 0) 19 | } 20 | } 21 | 22 | @Composable 23 | fun Toaster(toast: ToastMessage?) { 24 | toast ?: return 25 | 26 | if (toast.isEmpty()) return 27 | 28 | val context = LocalContext.current 29 | LaunchedEffect(toast) { 30 | if (toast.resId != null && toast.resId != 0) { 31 | Toast.makeText(context, toast.resId, toast.duration).show() 32 | } else { 33 | Toast.makeText(context, toast.text, toast.duration).show() 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/detail/FileNode.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.detail 2 | 3 | import io.github.bkmioa.nexusrss.model.FileItem 4 | 5 | 6 | data class FileNode( 7 | val name: String, 8 | val depth: Int, 9 | val children: MutableList = mutableListOf(), 10 | var sizeText: String? = null, 11 | ) { 12 | val isDirectory: Boolean 13 | get() = children.isNotEmpty() 14 | } 15 | 16 | fun List.toHierarchy(): List { 17 | val tempRoot = FileNode(name = "", depth = -1) 18 | 19 | for (file in this) { 20 | val segments = file.name.split('/') 21 | 22 | if (segments.isEmpty()) continue 23 | 24 | var current = tempRoot 25 | segments.forEachIndexed { index, segment -> 26 | val child = current.children.find { it.name == segment } 27 | ?: FileNode(name = segment, depth = current.depth + 1).also { current.children.add(it) } 28 | 29 | if (index == segments.lastIndex) { 30 | child.sizeText = file.sizeText 31 | } else { 32 | child.sizeText = null 33 | } 34 | 35 | current = child 36 | } 37 | } 38 | return tempRoot.children 39 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_default_avatar.xml: -------------------------------------------------------------------------------- 1 | 6 | 12 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/widget/PullToRefreshBox.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.widget 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.BoxScope 5 | import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults.Indicator 6 | import androidx.compose.material3.pulltorefresh.PullToRefreshState 7 | import androidx.compose.material3.pulltorefresh.pullToRefresh 8 | import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState 9 | import androidx.compose.runtime.Composable 10 | import androidx.compose.ui.Alignment 11 | import androidx.compose.ui.Modifier 12 | 13 | @Composable 14 | fun PullToRefreshBox( 15 | modifier: Modifier = Modifier, 16 | enabled: Boolean = true, 17 | isRefreshing: Boolean, 18 | onRefresh: () -> Unit, 19 | state: PullToRefreshState = rememberPullToRefreshState(), 20 | contentAlignment: Alignment = Alignment.TopStart, 21 | indicator: @Composable BoxScope.() -> Unit = { 22 | Indicator( 23 | modifier = Modifier.align(Alignment.TopCenter), 24 | isRefreshing = isRefreshing, 25 | state = state, 26 | ) 27 | }, 28 | content: @Composable BoxScope.() -> Unit, 29 | ) { 30 | Box( 31 | modifier.pullToRefresh(enabled = enabled, state = state, isRefreshing = isRefreshing, onRefresh = onRefresh), 32 | contentAlignment = contentAlignment, 33 | ) { 34 | content() 35 | indicator() 36 | } 37 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/theme/AppTheme.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.theme 2 | 3 | import android.os.Build 4 | import androidx.compose.foundation.isSystemInDarkTheme 5 | import androidx.compose.material3.MaterialTheme 6 | import androidx.compose.material3.darkColorScheme 7 | import androidx.compose.material3.lightColorScheme 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.SideEffect 10 | import androidx.compose.ui.platform.LocalContext 11 | import androidx.compose.ui.platform.LocalView 12 | import androidx.core.view.ViewCompat 13 | 14 | @Composable 15 | fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) { 16 | val colorScheme = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { 17 | val context = LocalContext.current 18 | 19 | if (darkTheme) dynamicDarkColorScheme(context) 20 | else dynamicLightColorScheme(context) 21 | 22 | } else if (!darkTheme) { 23 | lightColorScheme() 24 | } else { 25 | darkColorScheme() 26 | } 27 | val view = LocalView.current 28 | if (!view.isInEditMode) { 29 | SideEffect { 30 | ViewCompat.getWindowInsetsController(view)?.apply { 31 | isAppearanceLightStatusBars = !darkTheme 32 | isAppearanceLightNavigationBars = !darkTheme 33 | } 34 | } 35 | } 36 | MaterialTheme( 37 | colorScheme = colorScheme, 38 | content = content 39 | ) 40 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/widget/SlantedRightPartShape.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.widget 2 | 3 | import androidx.compose.ui.geometry.Rect 4 | import androidx.compose.ui.geometry.Size 5 | import androidx.compose.ui.graphics.Outline 6 | import androidx.compose.ui.graphics.Path 7 | import androidx.compose.ui.graphics.Shape 8 | import androidx.compose.ui.unit.Density 9 | import androidx.compose.ui.unit.Dp 10 | import androidx.compose.ui.unit.LayoutDirection 11 | 12 | /** 13 | * 一个自定义 Shape,用于裁剪出右侧的斜切部分。 14 | * @param slant 斜线在水平方向上的宽度。 15 | */ 16 | class SlantedRightPartShape(private val slant: Dp, private val radius: Dp) : Shape { 17 | override fun createOutline( 18 | size: Size, 19 | layoutDirection: LayoutDirection, 20 | density: Density 21 | ): Outline { 22 | val slantPx = with(density) { 23 | slant.toPx() 24 | } 25 | val radiusPx = with(density) { 26 | radius.toPx() 27 | } 28 | val path = Path().apply { 29 | moveTo(slantPx, 0f) 30 | lineTo(size.width - radiusPx / 2, 0f) 31 | arcTo(Rect(size.width - 2 * radiusPx, 0f, size.width, 2 * radiusPx), -90f, 90f, false) 32 | lineTo(size.width, size.height - radiusPx / 2) 33 | arcTo(Rect(size.width - 2 * radiusPx, size.height - 2 * radiusPx, size.width, size.height), 0f, 90f, false) 34 | lineTo(0f, size.height) 35 | close() 36 | } 37 | return Outline.Generic(path) 38 | } 39 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/detail/RichContent.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.detail 2 | 3 | import android.net.Uri 4 | import androidx.compose.foundation.layout.fillMaxWidth 5 | import androidx.compose.foundation.layout.padding 6 | import androidx.compose.runtime.Composable 7 | import androidx.compose.runtime.remember 8 | import androidx.compose.ui.Modifier 9 | import androidx.compose.ui.unit.dp 10 | import androidx.navigation.NavOptions 11 | import io.github.bkmioa.nexusrss.LocalNavController 12 | import io.github.bkmioa.nexusrss.bbcode.BbCodeContent 13 | import io.github.bkmioa.nexusrss.util.convertMarkdown2BBCode 14 | 15 | @Composable 16 | fun RichContent(data: String?) { 17 | if (data.isNullOrBlank()) return 18 | 19 | val controller = LocalNavController.current 20 | val navOptions = remember { NavOptions.Builder().setRestoreState(true).build() } 21 | val handleLink: (String) -> Boolean = remember(controller, navOptions) { 22 | { target -> 23 | val uri = runCatching { Uri.parse(target) }.getOrNull() ?: return@remember false 24 | if (uri.host?.endsWith("m-team.cc") == true && uri.path?.startsWith("/detail") == true) { 25 | controller.navigate(uri, navOptions) 26 | true 27 | } else { 28 | false 29 | } 30 | } 31 | } 32 | BbCodeContent( 33 | text = remember { convertMarkdown2BBCode(data) }, 34 | modifier = Modifier 35 | .fillMaxWidth() 36 | .padding(12.dp), 37 | onLinkClick = handleLink 38 | ) 39 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/download/DownloadReceiver.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.download 2 | 3 | import android.content.BroadcastReceiver 4 | import android.content.Context 5 | import android.content.Intent 6 | import androidx.core.app.NotificationManagerCompat 7 | 8 | class DownloadReceiver : BroadcastReceiver() { 9 | companion object { 10 | private const val KEY_NOTIFICATION_ID = "notification_id" 11 | private const val KEY_DOWNLOAD_TASK = "download_task" 12 | private const val ACTION_DOWNLOAD = "io.github.bkmioa.nexusrss.action_download" 13 | 14 | fun createDownloadIntent(context: Context, notificationId: Int? = null, downloadTask: DownloadTask? = null): Intent { 15 | return Intent(context, DownloadReceiver::class.java).apply { 16 | action = ACTION_DOWNLOAD 17 | putExtra(KEY_NOTIFICATION_ID, notificationId) 18 | putExtra(KEY_DOWNLOAD_TASK, downloadTask) 19 | } 20 | } 21 | } 22 | 23 | override fun onReceive(context: Context, intent: Intent?) { 24 | if (intent?.action == ACTION_DOWNLOAD) { 25 | val downloadTask = intent.getParcelableExtra(KEY_DOWNLOAD_TASK) ?: return 26 | 27 | RemoteDownloader.download(context, downloadTask) 28 | 29 | if (intent.hasExtra(KEY_NOTIFICATION_ID)) { 30 | val notificationId = intent.getIntExtra(KEY_NOTIFICATION_ID, 0) 31 | NotificationManagerCompat.from(context).cancel(notificationId) 32 | } 33 | 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/repository/MtService.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.repository 2 | 3 | import io.github.bkmioa.nexusrss.model.Comment 4 | import io.github.bkmioa.nexusrss.model.CommentRequestBody 5 | import io.github.bkmioa.nexusrss.model.FileItem 6 | import io.github.bkmioa.nexusrss.model.Item 7 | import io.github.bkmioa.nexusrss.model.ItemList 8 | import io.github.bkmioa.nexusrss.model.MemberInfo 9 | import io.github.bkmioa.nexusrss.model.MemberRequestBody 10 | import io.github.bkmioa.nexusrss.model.RequestData 11 | import io.github.bkmioa.nexusrss.model.Result 12 | import retrofit2.http.Body 13 | import retrofit2.http.Field 14 | import retrofit2.http.FormUrlEncoded 15 | import retrofit2.http.POST 16 | 17 | interface MtService { 18 | 19 | @POST("api/torrent/search") 20 | suspend fun search(@Body requestData: RequestData): Result> 21 | 22 | @POST("api/torrent/detail") 23 | @FormUrlEncoded 24 | suspend fun getDetail(@Field("id") id: String): Result 25 | 26 | @POST("api/torrent/genDlToken") 27 | @FormUrlEncoded 28 | suspend fun getDownloadLink(@Field("id") id: String): Result 29 | 30 | @POST("api/torrent/files") 31 | @FormUrlEncoded 32 | suspend fun getFileList(@Field("id") id: String): Result> 33 | 34 | @POST("api/comment/fetchList") 35 | suspend fun getComments(@Body requestData: CommentRequestBody): Result> 36 | 37 | @POST("api/member/bases") 38 | suspend fun getMemberInfos(@Body body: MemberRequestBody): Result> 39 | } 40 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/DownloadNodeModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | import android.os.Parcelable 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import io.github.bkmioa.nexusrss.download.DownloadNode 7 | import io.github.bkmioa.nexusrss.download.QBittorrentNode 8 | import io.github.bkmioa.nexusrss.download.TransmissionNode 9 | import io.github.bkmioa.nexusrss.download.UTorrentNode 10 | import kotlinx.parcelize.Parcelize 11 | 12 | @Entity(tableName = "download_node") 13 | @Parcelize 14 | data class DownloadNodeModel( 15 | val name: String, 16 | val host: String, 17 | val userName: String, 18 | val password: String, 19 | val type: String, 20 | val defaultPath: String? = null, 21 | @PrimaryKey(autoGenerate = true) 22 | var id: Long? = null 23 | ) : Parcelable { 24 | companion object { 25 | const val TYPE_UTORRENT = "uTorrent" 26 | const val TYPE_TRANSMISSION = "Transmission" 27 | const val TYPE_QBITTORRENT = "qBittorrent" 28 | 29 | val ALL_TYPES = arrayOf( 30 | TYPE_QBITTORRENT, 31 | TYPE_TRANSMISSION, 32 | TYPE_UTORRENT, 33 | ) 34 | } 35 | 36 | fun toDownloadNode(): DownloadNode { 37 | return when (type) { 38 | TYPE_UTORRENT -> UTorrentNode(host, userName, password, defaultPath) 39 | TYPE_TRANSMISSION -> TransmissionNode(host, userName, password, defaultPath) 40 | TYPE_QBITTORRENT, -> QBittorrentNode(host, userName, password, defaultPath) 41 | else -> throw Throwable("Unsupported type: $type") 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/search/SearchHistoryStore.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.search 2 | 3 | import android.content.SharedPreferences 4 | import com.chibatching.kotpref.KotprefModel 5 | import kotlinx.coroutines.channels.awaitClose 6 | import kotlinx.coroutines.flow.Flow 7 | import kotlinx.coroutines.flow.callbackFlow 8 | 9 | object SearchHistoryStore : KotprefModel() { 10 | private const val MAX_SIZE = 20 11 | 12 | override val kotprefName: String 13 | get() = "recent_search" 14 | 15 | fun put(keywords: String) { 16 | val all = getAll() 17 | 18 | val editor = preferences.edit() 19 | .putLong(keywords, System.currentTimeMillis()) 20 | 21 | if (all.size > MAX_SIZE) { 22 | editor.remove(all.last()) 23 | } 24 | 25 | editor.apply() 26 | } 27 | 28 | fun remove(keywords: String) { 29 | preferences.edit() 30 | .remove(keywords) 31 | .apply() 32 | } 33 | 34 | fun getAllFlow(): Flow> = callbackFlow { 35 | val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> 36 | trySend(getAll()) 37 | } 38 | 39 | preferences.registerOnSharedPreferenceChangeListener(listener) 40 | 41 | trySend(getAll()) 42 | 43 | awaitClose { 44 | preferences.unregisterOnSharedPreferenceChangeListener(listener) 45 | } 46 | } 47 | 48 | fun getAll(): List { 49 | val all: Map = preferences.all as Map 50 | 51 | return all.keys.sortedWith(compareByDescending { all[it] }) 52 | } 53 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/cookie/SharedCookieJar.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.cookie 2 | 3 | import android.webkit.CookieManager 4 | import okhttp3.Cookie 5 | import okhttp3.CookieJar 6 | import okhttp3.HttpUrl 7 | import java.net.CookieHandler 8 | import java.net.URI 9 | 10 | class SharedCookieJar : CookieHandler(), CookieJar { 11 | private val cookieManager: CookieManager = CookieManager.getInstance() 12 | 13 | override fun get(uri: URI, requestHeaders: Map?>?): Map> { 14 | val url: String = uri.toString() 15 | val cookieValue: String = cookieManager.getCookie(url) ?: "" 16 | return mapOf("Cookie" to listOf(cookieValue)) 17 | } 18 | 19 | override fun put(uri: URI, responseHeaders: Map>) { 20 | val url: String = uri.toString() 21 | for (header in responseHeaders.keys) { 22 | if (header.equals("Set-Cookie", ignoreCase = true) || header.equals("Set-Cookie2", ignoreCase = true)) { 23 | responseHeaders[header]?.forEach { value -> 24 | cookieManager.setCookie(url, value) 25 | } 26 | } 27 | } 28 | } 29 | 30 | override fun loadForRequest(url: HttpUrl): List { 31 | return when (val cookies = cookieManager.getCookie(url.toString())) { 32 | null -> emptyList() 33 | else -> cookies.split("; ").mapNotNull { Cookie.parse(url, it) } 34 | } 35 | } 36 | 37 | override fun saveFromResponse(url: HttpUrl, cookies: List) { 38 | cookies.forEach { cookie -> 39 | cookieManager.setCookie(url.toString(), cookie.toString()) 40 | } 41 | } 42 | } -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # By default, the flags in this file are appended to flags specified 3 | # in /Users/bkmi/android-sdk-macosx/tools/proguard/proguard-android.txt 4 | # You can edit the include path and order by changing the proguardFiles 5 | # directive in build.gradle. 6 | # 7 | # For more details, see 8 | # http://developer.android.com/guide/developing/tools/proguard.html 9 | 10 | # Add any project specific keep options here: 11 | 12 | # If your project uses WebView with JS, uncomment the following 13 | # and specify the fully qualified class name to the JavaScript interface 14 | # class: 15 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 16 | # public *; 17 | #} 18 | 19 | # Uncomment this to preserve the line number information for 20 | # debugging stack traces. 21 | #-keepattributes SourceFile,LineNumberTable 22 | 23 | # If you keep the line number information, uncomment this to 24 | # hide the original source file name. 25 | #-renamesourcefileattribute SourceFile 26 | 27 | # okhttp 28 | -dontwarn okio.** 29 | -dontwarn javax.annotation.Nullable 30 | -dontwarn javax.annotation.ParametersAreNonnullByDefault 31 | 32 | # retrofit 33 | # Platform calls Class.forName on types which do not exist on Android to determine platform. 34 | -dontnote retrofit2.Platform 35 | # Platform used when running on Java 8 VMs. Will not be used at runtime. 36 | -dontwarn retrofit2.Platform$Java8 37 | # Retain generic type information for use by reflection by converters and adapters. 38 | -keepattributes Signature 39 | # Retain declared checked exceptions for use by a Proxy instance. 40 | -keepattributes Exceptions 41 | 42 | # models 43 | -keep class io.github.bkmioa.**.model.**{ 44 | *; 45 | } 46 | 47 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/SharedTransitionScope.kt: -------------------------------------------------------------------------------- 1 | @file:OptIn(ExperimentalSharedTransitionApi::class) 2 | 3 | package io.github.bkmioa.nexusrss 4 | 5 | import androidx.compose.animation.AnimatedVisibilityScope 6 | import androidx.compose.animation.ExperimentalSharedTransitionApi 7 | import androidx.compose.animation.SharedTransitionScope 8 | import androidx.compose.runtime.Composable 9 | import androidx.compose.runtime.compositionLocalOf 10 | import androidx.compose.runtime.remember 11 | import androidx.compose.ui.Modifier 12 | 13 | data class SharedTransitionData( 14 | val animatedVisibilityScope: AnimatedVisibilityScope, 15 | val sharedTransitionScope: SharedTransitionScope, 16 | ) 17 | 18 | val LocalSharedTransitionData = compositionLocalOf { null } 19 | 20 | interface SharedTransitionModifierScope: SharedTransitionScope, Modifier 21 | 22 | private class SharedTransitionModifierScopeImpl( 23 | sharedTransitionScope: SharedTransitionScope, 24 | modifier: Modifier, 25 | ): SharedTransitionScope by sharedTransitionScope, Modifier by modifier, 26 | SharedTransitionModifierScope 27 | 28 | @Composable 29 | fun Modifier.sharedTransitionScope( 30 | scope: @Composable SharedTransitionModifierScope.(animatedVisibilityScope: AnimatedVisibilityScope) -> Modifier 31 | ): Modifier { 32 | val data = LocalSharedTransitionData.current 33 | return if (data == null) { 34 | this 35 | } else { 36 | val scopeImpl = remember { 37 | SharedTransitionModifierScopeImpl( 38 | sharedTransitionScope = data.sharedTransitionScope, 39 | modifier = this 40 | ) 41 | } 42 | scope(scopeImpl, data.animatedVisibilityScope) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/Tab.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | import android.os.Parcelable 4 | import androidx.room.ColumnInfo 5 | import androidx.room.Entity 6 | import androidx.room.PrimaryKey 7 | import kotlinx.parcelize.Parcelize 8 | 9 | @Entity(tableName = "tab") 10 | @Parcelize 11 | data class Tab( 12 | val title: String, 13 | 14 | /** 15 | * 类型 16 | */ 17 | val mode: String, 18 | 19 | /** 20 | * 類別 21 | */ 22 | val categories: Set = emptySet(), 23 | 24 | /** 25 | * 解析度 26 | */ 27 | val standards: Set? = null, 28 | 29 | /** 30 | * 視頻編碼 31 | */ 32 | val videoCodecs: Set? = null, 33 | 34 | /** 35 | * 音頻編碼 36 | */ 37 | val audioCodecs: Set? = null, 38 | 39 | /** 40 | * 地區 41 | */ 42 | val processings: Set? = null, 43 | 44 | /** 45 | * 製作組 46 | */ 47 | val teams: Set? = null, 48 | 49 | val labels: Set? = null, 50 | 51 | /** 52 | * 促銷 53 | */ 54 | val discount: String? = null, 55 | 56 | /** 57 | * 不限 0 58 | * 僅活躍 1 59 | * 僅死種 2 60 | */ 61 | @ColumnInfo(defaultValue = "1") 62 | val visible: Int = 1, 63 | 64 | val order: Int = 0, 65 | 66 | val isShow: Boolean = true, 67 | 68 | val columnCount: Int = 1, 69 | 70 | @PrimaryKey(autoGenerate = true) 71 | val id: Long? = null 72 | ) : Parcelable, Comparable { 73 | 74 | companion object { 75 | val EMPTY = Tab(title = "", mode = Mode.NORMAL.mode) 76 | } 77 | 78 | override fun compareTo(other: Tab) = this.order.compareTo(other.order) 79 | 80 | fun makeKey(): String { 81 | return hashCode().toString() 82 | } 83 | } 84 | 85 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/download/list/DownloadSettingsViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.download.list 2 | 3 | import com.airbnb.mvrx.MavericksState 4 | import com.airbnb.mvrx.MavericksViewModel 5 | import io.github.bkmioa.nexusrss.db.AppDatabase 6 | import io.github.bkmioa.nexusrss.model.DownloadNodeModel 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | import org.koin.core.component.KoinComponent 10 | import org.koin.core.component.inject 11 | 12 | data class UiState( 13 | val downloadNodes: List = emptyList(), 14 | val undoDeleteNode: DownloadNodeModel? = null, 15 | ) : MavericksState 16 | 17 | class DownloadSettingsViewModel(initialState: UiState) : MavericksViewModel(initialState), KoinComponent { 18 | 19 | 20 | private val appDatabase: AppDatabase by inject() 21 | 22 | private val downloadDao = appDatabase.downloadDao() 23 | 24 | init { 25 | downloadDao.getAllFlow().setOnEach { 26 | copy(downloadNodes = it) 27 | } 28 | } 29 | 30 | fun duplicateNode(node: DownloadNodeModel) { 31 | downloadDao.addOrUpdateNode(node.copy(id = null)) 32 | } 33 | 34 | fun deleteNode(node: DownloadNodeModel) { 35 | downloadDao.delete(node) 36 | setState { copy(undoDeleteNode = node) } 37 | } 38 | 39 | suspend fun performUndoDelete() { 40 | awaitState().undoDeleteNode?.let { 41 | withContext(Dispatchers.IO) { 42 | downloadDao.addOrUpdateNode(it) 43 | } 44 | } 45 | setState { 46 | copy(undoDeleteNode = null) 47 | } 48 | } 49 | 50 | fun resetUndoDelete() { 51 | setState { 52 | copy(undoDeleteNode = null) 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/bbcode/BbNodes.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.bbcode 2 | 3 | import androidx.compose.ui.text.font.FontFamily 4 | 5 | internal sealed interface BbNode { 6 | data class Text(val value: String) : BbNode 7 | data class Styled(val style: InlineStyle, val children: List) : BbNode 8 | data class Link(val target: String, val children: List) : BbNode 9 | data class Quote(val source: String?, val children: List) : BbNode 10 | data class Code(val code: String, val language: String? = null) : BbNode 11 | data class Image(val url: String) : BbNode 12 | data class Alignment(val alignment: AlignmentType, val children: List) : BbNode 13 | data class ListBlock(val ordered: Boolean, val items: List>) : BbNode 14 | data class Table(val rows: List) : BbNode 15 | 16 | data object LineBreak : BbNode 17 | data object HorizontalRule : BbNode 18 | 19 | /** 20 | * Internal helpers used while parsing lists. 21 | */ 22 | data object ListItemBreak : BbNode 23 | data class ListItem(val children: List) : BbNode 24 | } 25 | 26 | internal enum class AlignmentType { 27 | LEFT, CENTER, RIGHT, JUSTIFY 28 | } 29 | 30 | internal sealed interface InlineStyle { 31 | data object Bold : InlineStyle 32 | data object Italic : InlineStyle 33 | data object Underline : InlineStyle 34 | data object Strike : InlineStyle 35 | data class Color(val value: String) : InlineStyle 36 | data class Size(val points: Int) : InlineStyle 37 | data class Font(val family: String) : InlineStyle 38 | data object Subscript : InlineStyle 39 | data object Superscript : InlineStyle 40 | } 41 | 42 | internal data class TableRow(val cells: List) 43 | internal data class TableCell(val header: Boolean, val children: List) 44 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/wifi_error.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/App.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss 2 | 3 | import android.app.Application 4 | import android.os.Build 5 | import coil3.ImageLoader 6 | import coil3.PlatformContext 7 | import coil3.SingletonImageLoader 8 | import coil3.gif.AnimatedImageDecoder 9 | import coil3.gif.GifDecoder 10 | import coil3.network.okhttp.OkHttpNetworkFetcherFactory 11 | import coil3.request.crossfade 12 | import com.airbnb.mvrx.Mavericks 13 | import com.chibatching.kotpref.Kotpref 14 | import io.github.bkmioa.nexusrss.di.appModule 15 | import okhttp3.OkHttpClient 16 | import org.koin.android.ext.android.inject 17 | import org.koin.android.ext.koin.androidContext 18 | import org.koin.core.component.KoinComponent 19 | import org.koin.core.context.startKoin 20 | import org.koin.core.qualifier.Qualifier 21 | import org.koin.core.qualifier.named 22 | 23 | 24 | class App : Application(), SingletonImageLoader.Factory, KoinComponent { 25 | 26 | companion object { 27 | lateinit var instance: App 28 | private set 29 | } 30 | 31 | override fun onCreate() { 32 | super.onCreate() 33 | instance = this 34 | 35 | startKoin { 36 | androidContext(this@App) 37 | modules(appModule) 38 | } 39 | 40 | Kotpref.init(this) 41 | Mavericks.initialize(this) 42 | } 43 | 44 | override fun newImageLoader(context: PlatformContext): ImageLoader { 45 | val httpClient: OkHttpClient by inject(named("coil")) 46 | 47 | return ImageLoader.Builder(this) 48 | .crossfade(true) 49 | .components { 50 | add(OkHttpNetworkFetcherFactory(callFactory = { httpClient })) 51 | if (Build.VERSION.SDK_INT >= 28) { 52 | add(AnimatedImageDecoder.Factory()) 53 | } else { 54 | add(GifDecoder.Factory()) 55 | } 56 | } 57 | .build() 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/webview/WebImageLoader.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.webview 2 | 3 | import android.net.Uri 4 | import android.util.Log 5 | import android.webkit.WebResourceResponse 6 | import androidx.lifecycle.Lifecycle 7 | import coil3.annotation.ExperimentalCoilApi 8 | import coil3.imageLoader 9 | import coil3.request.ErrorResult 10 | import coil3.request.ImageRequest 11 | import coil3.request.SuccessResult 12 | import coil3.request.lifecycle 13 | import io.github.bkmioa.nexusrss.App 14 | import kotlinx.coroutines.runBlocking 15 | import java.io.FileInputStream 16 | 17 | object WebImageLoader { 18 | private const val TAG = "WebImageLoader" 19 | 20 | fun loadSync(url: Uri, lifecycle: Lifecycle): WebResourceResponse? = try { 21 | runBlocking { 22 | load(url, lifecycle) 23 | } 24 | } catch (e: Exception) { 25 | Log.e(TAG, "loadSync error", e) 26 | null 27 | } 28 | 29 | @OptIn(ExperimentalCoilApi::class) 30 | suspend fun load(url: Uri, lifecycle: Lifecycle): WebResourceResponse? { 31 | val request = ImageRequest.Builder(App.instance) 32 | .lifecycle(lifecycle) 33 | .data(url) 34 | .build() 35 | 36 | val imageLoader = App.instance.imageLoader 37 | val result = imageLoader.execute(request) 38 | if (result is ErrorResult) { 39 | Log.e(TAG, "load error", result.throwable) 40 | return null 41 | } 42 | 43 | result as SuccessResult 44 | 45 | val diskCacheKey = result.diskCacheKey ?: return null 46 | val diskCache = imageLoader.diskCache ?: return null 47 | val snapshot = diskCache.openSnapshot(diskCacheKey) ?: return null 48 | val cacheResponse = diskCache.fileSystem.read(snapshot.metadata) { 49 | CacheResponse(this) 50 | } 51 | val mimeType = cacheResponse.contentType.toString() 52 | val inputStream = FileInputStream(snapshot.data.toFile()) 53 | return WebResourceResponse(mimeType, "UTF-8", inputStream) 54 | } 55 | } -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | 9 | 10 | 23 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 45 | 46 | 47 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/model/RequestData.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.model 2 | 3 | import android.os.Parcelable 4 | import androidx.annotation.IntRange 5 | import kotlinx.parcelize.IgnoredOnParcel 6 | import kotlinx.parcelize.Parcelize 7 | 8 | 9 | @Parcelize 10 | data class RequestData( 11 | val mode: String? = null, 12 | 13 | /** 14 | * 关键字 15 | */ 16 | val keyword: String? = null, 17 | 18 | /** 19 | * 類別 20 | */ 21 | val categories: Set = emptySet(), 22 | 23 | /** 24 | * 解析度 25 | */ 26 | val standards: Set? = null, 27 | 28 | /** 29 | * 視頻編碼 30 | */ 31 | val videoCodecs: Set? = null, 32 | 33 | /** 34 | * 音頻編碼 35 | */ 36 | val audioCodecs: Set? = null, 37 | 38 | /** 39 | * 地區 40 | */ 41 | val processings: Set? = null, 42 | 43 | /** 44 | * 製作組 45 | */ 46 | val teams: Set? = null, 47 | 48 | 49 | val labelsNew: Set? = null, 50 | 51 | /** 52 | * 促銷 53 | */ 54 | val discount: String? = null, 55 | 56 | /** 57 | * 不限 0 58 | * 僅活躍 1 59 | * 僅死種 2 60 | */ 61 | val visible: Int? = 1, 62 | 63 | val pageSize: Int = 20, 64 | 65 | @IntRange(from = 1) 66 | @IgnoredOnParcel 67 | val pageNumber: Int = 1, 68 | 69 | val douban: String? = null, 70 | 71 | val imdb: String? = null, 72 | ) : Parcelable { 73 | companion object { 74 | fun from(tab: Tab): RequestData { 75 | return RequestData( 76 | mode = tab.mode, 77 | categories = tab.categories, 78 | standards = tab.standards, 79 | videoCodecs = tab.videoCodecs, 80 | audioCodecs = tab.audioCodecs, 81 | processings = tab.processings, 82 | teams = tab.teams, 83 | labelsNew = tab.labels, 84 | discount = tab.discount, 85 | visible = tab.visible 86 | ) 87 | } 88 | 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/tabs/TabsViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.tabs 2 | 3 | import com.airbnb.mvrx.MavericksState 4 | import com.airbnb.mvrx.MavericksViewModel 5 | import io.github.bkmioa.nexusrss.db.AppDatabase 6 | import io.github.bkmioa.nexusrss.model.Tab 7 | import kotlinx.coroutines.Dispatchers 8 | import kotlinx.coroutines.withContext 9 | import org.koin.core.component.KoinComponent 10 | import org.koin.core.component.inject 11 | 12 | data class UiState( 13 | val tabs: List = emptyList(), 14 | val undoDelete: Tab? = null, 15 | ) : MavericksState 16 | 17 | class TabsViewModel(initialState: UiState) : MavericksViewModel(initialState), KoinComponent { 18 | companion object { 19 | const val TAG = "TabsViewModel" 20 | } 21 | 22 | private val appDateBase: AppDatabase by inject() 23 | 24 | private val appDao = appDateBase.appDao() 25 | 26 | init { 27 | appDao.getAllTabFlow().setOnEach { 28 | copy(tabs = it) 29 | } 30 | } 31 | 32 | fun removeTab(tab: Tab) { 33 | appDao.deleteTab(tab) 34 | setState { copy(undoDelete = tab) } 35 | } 36 | 37 | fun update(tab: Tab) { 38 | appDao.updateTab(tab) 39 | } 40 | 41 | suspend fun reorderTabs(from: Int, to: Int) { 42 | setState { 43 | val mutable = tabs.toMutableList() 44 | mutable.add(to, mutable.removeAt(from)) 45 | val newList = mutable.mapIndexed { index, tab -> tab.copy(order = index) } 46 | copy(tabs = newList) 47 | } 48 | appDao.updateTab(* awaitState().tabs.toTypedArray()) 49 | } 50 | 51 | suspend fun performUndoDelete() { 52 | awaitState().undoDelete?.let { 53 | // Re-add the tab with a new id 54 | // because of https://slack-chats.kotlinlang.org/t/28924907/hi-everyone-wave-i-m-running-into-a-frustrating-issue-with-s 55 | appDao.addTab(it.copy(id = null)) 56 | } 57 | setState { 58 | copy(undoDelete = null) 59 | } 60 | 61 | } 62 | 63 | fun resetUndoDelete() { 64 | setState { 65 | copy(undoDelete = null) 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/res/drawable/empty.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/widget/LifecycleAware.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.widget 2 | 3 | import androidx.compose.runtime.Composable 4 | import androidx.compose.runtime.DisposableEffect 5 | import androidx.compose.runtime.getValue 6 | import androidx.compose.runtime.rememberUpdatedState 7 | import androidx.compose.ui.platform.LocalLifecycleOwner 8 | import androidx.lifecycle.Lifecycle 9 | import androidx.lifecycle.LifecycleEventObserver 10 | import androidx.lifecycle.LifecycleOwner 11 | 12 | //https://developer.android.com/jetpack/compose/side-effects#disposableeffect 13 | @Composable 14 | fun LifecycleAware( 15 | lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current, 16 | onStart: () -> Unit = {}, // Send the 'started' analytics event 17 | onResume: () -> Unit = {}, // Send the 'resumed' analytics event 18 | onPause: () -> Unit = {}, // Send the 'paused' analytics event 19 | onStop: () -> Unit = {}, // Send the 'stopped' analytics event 20 | content: @Composable () -> Unit 21 | ) { 22 | // Safely update the current lambdas when a new one is provided 23 | val currentOnStart by rememberUpdatedState(onStart) 24 | val currentOnResume by rememberUpdatedState(onResume) 25 | val currentOnPause by rememberUpdatedState(onPause) 26 | val currentOnStop by rememberUpdatedState(onStop) 27 | 28 | // If `lifecycleOwner` changes, dispose and reset the effect 29 | DisposableEffect(lifecycleOwner) { 30 | // Create an observer that triggers our remembered callbacks 31 | // for sending analytics events 32 | val observer = LifecycleEventObserver { _, event -> 33 | when (event) { 34 | Lifecycle.Event.ON_START -> currentOnStart() 35 | Lifecycle.Event.ON_RESUME -> currentOnResume() 36 | Lifecycle.Event.ON_PAUSE -> currentOnPause() 37 | Lifecycle.Event.ON_STOP -> currentOnStop() 38 | else -> Unit 39 | } 40 | } 41 | 42 | // Add the observer to the lifecycle 43 | lifecycleOwner.lifecycle.addObserver(observer) 44 | 45 | // When the effect leaves the Composition, remove the observer 46 | onDispose { 47 | lifecycleOwner.lifecycle.removeObserver(observer) 48 | } 49 | } 50 | 51 | content() 52 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/widget/Shadow.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.widget 2 | 3 | import androidx.compose.foundation.layout.Box 4 | import androidx.compose.foundation.layout.offset 5 | import androidx.compose.runtime.Composable 6 | import androidx.compose.ui.Modifier 7 | import androidx.compose.ui.draw.drawWithContent 8 | import androidx.compose.ui.geometry.toRect 9 | import androidx.compose.ui.graphics.BlendMode 10 | import androidx.compose.ui.graphics.Color 11 | import androidx.compose.ui.graphics.ColorFilter 12 | import androidx.compose.ui.graphics.Paint 13 | import androidx.compose.ui.graphics.drawscope.drawIntoCanvas 14 | import androidx.compose.ui.graphics.graphicsLayer 15 | import androidx.compose.ui.graphics.withSaveLayer 16 | import androidx.compose.ui.unit.Dp 17 | import androidx.compose.ui.unit.dp 18 | import dev.chrisbanes.haze.hazeEffect 19 | 20 | @Composable 21 | fun Shadow( 22 | modifier: Modifier = Modifier, 23 | blurRadius: Dp = 4.dp, 24 | spread: Dp = 2.dp, 25 | offsetX: Dp = 0.dp, 26 | offsetY: Dp = 2.dp, 27 | color: Color = Color.Black.copy(alpha = 0.6f), 28 | content: @Composable () -> Unit 29 | ) { 30 | Box(modifier = modifier) { 31 | Box( 32 | modifier = Modifier 33 | .offset(x = offsetX, y = offsetY) 34 | .graphicsLayer { 35 | if (size.width > 0 && size.height > 0) { 36 | val pxSpread = spread.toPx() 37 | scaleX = (size.width + pxSpread * 2) / size.width 38 | scaleY = (size.height + pxSpread * 2) / size.height 39 | } 40 | } 41 | .hazeEffect { 42 | this.blurRadius = blurRadius 43 | } 44 | .forceShadowColor(color) 45 | ) { 46 | content() 47 | } 48 | 49 | content() 50 | } 51 | } 52 | 53 | fun Modifier.forceShadowColor(color: Color): Modifier = this.drawWithContent { 54 | val shadowColorFilter = ColorFilter.tint(color, BlendMode.SrcIn) 55 | val paint = Paint().apply { 56 | colorFilter = shadowColorFilter 57 | } 58 | 59 | drawIntoCanvas { canvas -> 60 | canvas.withSaveLayer(size.toRect(), paint) { 61 | this@drawWithContent.drawContent() 62 | } 63 | } 64 | } -------------------------------------------------------------------------------- /.github/workflows/android.yml: -------------------------------------------------------------------------------- 1 | name: Android CI 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | tags: 7 | - 'v*' 8 | pull_request: 9 | branches: [ "master" ] 10 | 11 | jobs: 12 | build: 13 | 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Fetch Tags 22 | run: | 23 | git fetch --tags --force 24 | 25 | - name: set up JDK 26 | uses: actions/setup-java@v5 27 | with: 28 | java-version: '21' 29 | distribution: 'temurin' 30 | 31 | - name: Write key 32 | if: ${{ ( github.event_name != 'pull_request' && github.ref == 'refs/heads/master' ) || github.ref_type == 'tag' }} 33 | run: | 34 | if [ ! -z "${{ secrets.KEY_STORE }}" ]; then 35 | echo >> gradle.properties 36 | echo mtStorePassword='${{ secrets.KEY_STORE_PASSWORD }}' >> gradle.properties 37 | echo mtKeyAlias='${{ secrets.KEY_ALIAS }}' >> gradle.properties 38 | echo mtKeyPassword='${{ secrets.KEY_PASSWORD }}' >> gradle.properties 39 | echo mtStoreFile='key.jks' >> gradle.properties 40 | echo ${{ secrets.KEY_STORE }} | base64 --decode > key.jks 41 | fi 42 | 43 | - name: Setup Gradle 44 | uses: gradle/actions/setup-gradle@v5 45 | 46 | - name: Build Debug 47 | if: github.ref_type != 'tag' 48 | run: ./gradlew app:assembleDebug 49 | 50 | - name: Build Release 51 | if: github.event_name != 'pull_request' 52 | run: ./gradlew app:assembleRelease 53 | 54 | - name: Upload a Build Artifact 55 | uses: actions/upload-artifact@v4 56 | with: 57 | path: | 58 | app/build/outputs/apk/debug/*.apk 59 | app/build/outputs/apk/release/*.apk 60 | 61 | - name: Get tag annotation 62 | id: tag 63 | if: github.ref_type == 'tag' 64 | run: | 65 | { 66 | echo 'annotation<>"${GITHUB_OUTPUT}" 70 | 71 | - name: Create Release 72 | if: github.ref_type == 'tag' 73 | uses: softprops/action-gh-release@v2 74 | with: 75 | tag_name: ${{ github.ref_name }} 76 | name: ${{ github.ref_name }} 77 | body: ${{ steps.tag.outputs.annotation }} 78 | files: app/build/outputs/apk/release/*.apk 79 | prerelease: ${{ contains(github.ref_name, '-') }} -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/download/QBittorrentNode.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.download 2 | 3 | import io.reactivex.Single 4 | import kotlinx.parcelize.Parcelize 5 | import okhttp3.MultipartBody 6 | import okhttp3.OkHttpClient 7 | import okhttp3.RequestBody 8 | import okhttp3.RequestBody.Companion.toRequestBody 9 | import okhttp3.ResponseBody 10 | import okhttp3.java.net.cookiejar.JavaNetCookieJar 11 | import retrofit2.Retrofit 12 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 13 | import retrofit2.http.* 14 | import java.net.CookieManager 15 | 16 | 17 | @Parcelize 18 | class QBittorrentNode( 19 | override val host: String, 20 | override val userName: String, 21 | override val password: String, 22 | override val defaultPath: String? 23 | ) : DownloadNode { 24 | 25 | override fun download(torrentUrl: String, path: String?): Single { 26 | val service: UTorrentService = getService() 27 | 28 | return service.login(userName, password) 29 | .flatMap { 30 | service.addTorrent( 31 | torrentUrl.toRequestBody(MultipartBody.FORM), 32 | (path ?: defaultPath)?.toRequestBody(MultipartBody.FORM), 33 | false.toString().toRequestBody(MultipartBody.FORM), 34 | ) 35 | } 36 | .map { "add success" } 37 | } 38 | 39 | private fun getService(): UTorrentService { 40 | val httpclient = OkHttpClient.Builder() 41 | .cookieJar(JavaNetCookieJar(CookieManager())) 42 | .build() 43 | val baseUrl = if (host.endsWith("\\")) host else host + "\\" 44 | return Retrofit.Builder() 45 | .baseUrl(baseUrl) 46 | .client(httpclient) 47 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 48 | .build() 49 | .create(UTorrentService::class.java) 50 | } 51 | 52 | interface UTorrentService { 53 | @FormUrlEncoded 54 | @POST("/api/v2/auth/login") 55 | fun login( 56 | @Field("username") userName: String, 57 | @Field("password") password: String 58 | ): Single 59 | 60 | @Multipart 61 | @POST("/api/v2/torrents/add") 62 | fun addTorrent( 63 | @Part("urls") urls: RequestBody, 64 | @Part("savepath") savePath: RequestBody?, 65 | @Part("autoTMM") autoTMM: RequestBody 66 | ): Single 67 | } 68 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/base/Pager.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.base 2 | 3 | import androidx.annotation.IntRange 4 | import androidx.paging.Pager as PagingPager 5 | import androidx.paging.PagingConfig 6 | import androidx.paging.PagingData 7 | import androidx.paging.PagingSource 8 | import androidx.paging.cachedIn 9 | import kotlinx.coroutines.CoroutineScope 10 | import kotlinx.coroutines.flow.Flow 11 | 12 | class Pager @JvmOverloads constructor( 13 | coroutineScope: CoroutineScope, 14 | 15 | /** 16 | * 每页加载的数量,默认为 15 17 | */ 18 | pageSize: Int = 15, 19 | 20 | /** 21 | * 到达列表末尾/头部多少条数据时开始加载下一页/上一页,默认为 5 22 | */ 23 | @IntRange(from = 0) 24 | prefetchDistance: Int = 5, 25 | 26 | /** 27 | * 第一次加载的数量,默认和 [pageSize] 一致 28 | */ 29 | @IntRange(from = 1) 30 | initialLoadSize: Int = pageSize, 31 | 32 | /** 33 | * 第一次加载使用的 key 34 | */ 35 | initialKey: Key? = null, 36 | 37 | /** 38 | * 列表支持的最大数量,当超过这个数量后,超过列表数据不会吐给下游,在加载超大列表时可减轻下游展示压力 39 | * 默认最大数列无限制 40 | */ 41 | @IntRange(from = 2) 42 | maxSize: Int = PagingConfig.MAX_SIZE_UNBOUNDED, 43 | 44 | /** 45 | * 跳转阈值,当跳转的条数超过这个阈值时,会直接跳转到目标页,而不是逐页加载 46 | * 默认跳转阈值无限制 47 | */ 48 | jumpThreshold: Int = PagingSource.LoadResult.Page.COUNT_UNDEFINED, 49 | 50 | enablePlaceholders: Boolean = false, 51 | /** 52 | * 提供数据源,每次需要返回一个新的实例 53 | */ 54 | pagingFactory: PagingSourceFactory, 55 | ) { 56 | fun interface PagingSourceFactory { 57 | fun create(): PagingSource 58 | } 59 | 60 | private val pager: PagingPager = PagingPager( 61 | config = PagingConfig( 62 | pageSize = pageSize, 63 | prefetchDistance = prefetchDistance, 64 | enablePlaceholders = enablePlaceholders, 65 | initialLoadSize = initialLoadSize, 66 | maxSize = maxSize, 67 | jumpThreshold = jumpThreshold, 68 | ), 69 | initialKey = initialKey, 70 | pagingSourceFactory = { 71 | pagingFactory.create().also { 72 | currentPagingSource = it 73 | } 74 | }) 75 | 76 | var currentPagingSource: PagingSource? = null 77 | private set 78 | 79 | val flow: Flow> = pager.flow.cachedIn(coroutineScope) 80 | 81 | fun invalidate() { 82 | currentPagingSource?.invalidate() 83 | } 84 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/webview/CacheResponse.kt: -------------------------------------------------------------------------------- 1 | //copy from coil.network.CacheResponse 2 | package io.github.bkmioa.nexusrss.webview 3 | 4 | import okhttp3.CacheControl 5 | import okhttp3.Headers 6 | import okhttp3.MediaType.Companion.toMediaTypeOrNull 7 | import okhttp3.Response 8 | import okio.BufferedSink 9 | import okio.BufferedSource 10 | 11 | internal class CacheResponse { 12 | 13 | val cacheControl by lazy(LazyThreadSafetyMode.NONE) { CacheControl.parse(responseHeaders) } 14 | val contentType by lazy(LazyThreadSafetyMode.NONE) { responseHeaders["Content-Type"]?.toMediaTypeOrNull() } 15 | val sentRequestAtMillis: Long 16 | val receivedResponseAtMillis: Long 17 | val isTls: Boolean 18 | val responseHeaders: Headers 19 | 20 | constructor(source: BufferedSource) { 21 | this.sentRequestAtMillis = source.readUtf8LineStrict().toLong() 22 | this.receivedResponseAtMillis = source.readUtf8LineStrict().toLong() 23 | this.isTls = source.readUtf8LineStrict().toInt() > 0 24 | val responseHeadersLineCount = source.readUtf8LineStrict().toInt() 25 | val responseHeaders = Headers.Builder() 26 | for (i in 0 until responseHeadersLineCount) { 27 | responseHeaders.addUnsafeNonAscii(source.readUtf8LineStrict()) 28 | } 29 | this.responseHeaders = responseHeaders.build() 30 | } 31 | 32 | constructor(response: Response) { 33 | this.sentRequestAtMillis = response.sentRequestAtMillis 34 | this.receivedResponseAtMillis = response.receivedResponseAtMillis 35 | this.isTls = response.handshake != null 36 | this.responseHeaders = response.headers 37 | } 38 | 39 | fun writeTo(sink: BufferedSink) { 40 | sink.writeDecimalLong(sentRequestAtMillis).writeByte('\n'.code) 41 | sink.writeDecimalLong(receivedResponseAtMillis).writeByte('\n'.code) 42 | sink.writeDecimalLong(if (isTls) 1L else 0L).writeByte('\n'.code) 43 | sink.writeDecimalLong(responseHeaders.size.toLong()).writeByte('\n'.code) 44 | for (i in 0 until responseHeaders.size) { 45 | sink.writeUtf8(responseHeaders.name(i)) 46 | .writeUtf8(": ") 47 | .writeUtf8(responseHeaders.value(i)) 48 | .writeByte('\n'.code) 49 | } 50 | } 51 | } 52 | 53 | internal fun Headers.Builder.addUnsafeNonAscii(line: String) = apply { 54 | val index = line.indexOf(':') 55 | require(index != -1) { "Unexpected header: $line" } 56 | addUnsafeNonAscii(line.substring(0, index).trim(), line.substring(index + 1)) 57 | } 58 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/download/UTorrentNode.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.download 2 | 3 | import io.reactivex.Single 4 | import kotlinx.parcelize.Parcelize 5 | import okhttp3.Credentials 6 | import okhttp3.OkHttpClient 7 | import okhttp3.ResponseBody 8 | import okhttp3.java.net.cookiejar.JavaNetCookieJar 9 | import retrofit2.Retrofit 10 | import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory 11 | import retrofit2.converter.gson.GsonConverterFactory 12 | import retrofit2.http.GET 13 | import retrofit2.http.Query 14 | import java.net.CookieManager 15 | import java.net.URLEncoder 16 | 17 | @Parcelize 18 | class UTorrentNode( 19 | override val host: String, 20 | override val userName: String, 21 | override val password: String, 22 | override val defaultPath: String? 23 | ) : DownloadNode { 24 | 25 | override fun download(torrentUrl: String, path: String?): Single { 26 | val service: UTorrentService = getService() 27 | return service.token() 28 | .flatMap { service.addUrl(getTokenFromBody(it.string()), URLEncoder.encode(torrentUrl)) } 29 | .map { "add success" } 30 | } 31 | 32 | private fun getTokenFromBody(html: String): String { 33 | return Regex("") 34 | .find(html)?.groupValues?.getOrNull(1) ?: throw IllegalStateException() 35 | } 36 | 37 | private fun getService(): UTorrentService { 38 | val httpclient = OkHttpClient.Builder() 39 | //.addInterceptor(httpLoggingInterceptor) 40 | .cookieJar(JavaNetCookieJar(CookieManager())) 41 | .authenticator { _, response -> 42 | response.request.newBuilder() 43 | .header("Authorization", Credentials.basic(userName, password)) 44 | .build() 45 | } 46 | .build() 47 | val baseUrl = if (host.endsWith("\\")) host else host + "\\" 48 | return Retrofit.Builder() 49 | .baseUrl(baseUrl) 50 | .client(httpclient) 51 | .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) 52 | .addConverterFactory(GsonConverterFactory.create()) 53 | .build() 54 | .create(UTorrentService::class.java) 55 | } 56 | 57 | interface UTorrentService { 58 | @GET("/gui/?action=add-url") 59 | fun addUrl( 60 | @Query("token") token: String, 61 | @Query(value = "s") torrentUrl: String 62 | ): Single 63 | 64 | @GET("/gui/token.html") 65 | fun token(): Single 66 | } 67 | } -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/checkversion/CheckVersionViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.checkversion 2 | 3 | import android.widget.Toast 4 | import com.airbnb.mvrx.MavericksState 5 | import com.airbnb.mvrx.MavericksViewModel 6 | import io.github.bkmioa.nexusrss.App 7 | import io.github.bkmioa.nexusrss.R 8 | import io.github.bkmioa.nexusrss.model.Release 9 | import io.github.bkmioa.nexusrss.repository.GithubService 10 | import io.github.g00fy2.versioncompare.Version 11 | import kotlinx.coroutines.Dispatchers 12 | import kotlinx.coroutines.launch 13 | import org.koin.core.component.KoinComponent 14 | import org.koin.core.component.inject 15 | 16 | data class UiState( 17 | val localVersion: String = "0.0.0", 18 | val remoteVersion: Release? = null, 19 | val canUpgrade: Boolean = false, 20 | val showSnapBar: Boolean = true, 21 | ) : MavericksState { 22 | 23 | } 24 | 25 | class CheckVersionViewModel(initialState: UiState) : MavericksViewModel(initialState), KoinComponent { 26 | private val githubService: GithubService by inject() 27 | 28 | init { 29 | val manager = App.instance.packageManager 30 | viewModelScope.launch(Dispatchers.IO) { 31 | manager.getPackageInfo(App.instance.packageName, 0)?.let { 32 | setState { copy(localVersion = it.versionName ?: "0.0.0") } 33 | } 34 | } 35 | checkVersion(true) 36 | } 37 | 38 | fun checkVersion(silent: Boolean = false) = viewModelScope.launch(Dispatchers.IO) { 39 | if (!silent) { 40 | toast(App.instance.getString(R.string.checking_version)) 41 | } 42 | val release = try { 43 | githubService.releaseList().firstOrNull() 44 | } catch (e: Exception) { 45 | e.printStackTrace() 46 | if (!silent) { 47 | toast(e.message ?: App.instance.getString(R.string.loading_error_toast)) 48 | } 49 | return@launch 50 | } 51 | val canUpgrade = if (release == null) false else canUpgrade(awaitState().localVersion, release.tagName) 52 | 53 | if (!canUpgrade && !silent) { 54 | toast(App.instance.getString(R.string.no_new_version)) 55 | } 56 | 57 | setState { 58 | copy(remoteVersion = release, canUpgrade = canUpgrade, showSnapBar = canUpgrade) 59 | } 60 | } 61 | 62 | private fun canUpgrade(localVersion: String, remoteVersion: String?): Boolean { 63 | remoteVersion ?: return false 64 | return Version(remoteVersion.trimStart('v', 'V')) > Version(localVersion) 65 | } 66 | 67 | private fun toast(message: String) { 68 | viewModelScope.launch(Dispatchers.Main) { 69 | Toast.makeText(App.instance, message, Toast.LENGTH_SHORT).show() 70 | } 71 | } 72 | 73 | 74 | fun setShowSnackBar(show: Boolean) { 75 | setState { copy(showSnapBar = show) } 76 | } 77 | } -------------------------------------------------------------------------------- /app/src/main/java/io/github/bkmioa/nexusrss/search/SearchViewModel.kt: -------------------------------------------------------------------------------- 1 | package io.github.bkmioa.nexusrss.search 2 | 3 | import com.airbnb.mvrx.MavericksState 4 | import com.airbnb.mvrx.MavericksViewModel 5 | import io.github.bkmioa.nexusrss.model.Mode 6 | import io.github.bkmioa.nexusrss.model.Option 7 | import io.github.bkmioa.nexusrss.model.RequestData 8 | 9 | data class UiState( 10 | val active: Boolean = true, 11 | /** 12 | * 关键字,对应 [RequestData.keyword] 13 | */ 14 | val keyword: String = "", 15 | val requestData: RequestData? = null, 16 | val history: List = emptyList() 17 | ) : MavericksState { 18 | val filteredList: List 19 | get() = if (keyword.isBlank()) history 20 | else history.filter { it.contains(keyword, ignoreCase = true) } 21 | } 22 | 23 | class SearchViewModel(initialState: UiState) : MavericksViewModel(initialState) { 24 | init { 25 | SearchHistoryStore.getAllFlow().setOnEach { 26 | copy(history = it) 27 | } 28 | } 29 | 30 | fun setActive(active: Boolean) { 31 | setState { 32 | copy(active = active) 33 | } 34 | } 35 | 36 | fun removeHistory(keywords: String) { 37 | SearchHistoryStore.remove(keywords) 38 | } 39 | 40 | fun setKeywords(keywords: String) { 41 | setState { 42 | copy(keyword = keywords) 43 | } 44 | } 45 | 46 | suspend fun submit( 47 | mode: Mode, 48 | categories: Set