feat: release QieKan 3.26.15

This commit is contained in:
2026-08-08 12:22:26 +08:00
parent 25e22b5711
commit 133315fcca
58 changed files with 1863 additions and 172 deletions
+3
View File
@@ -19,3 +19,6 @@ node_modules/
/.vscode/settings.json /.vscode/settings.json
/.codex/skills /.codex/skills
/.backup/ /.backup/
/.audiobookshelf/
/.gradle-codex/
/.tools/
+2 -2
View File
@@ -1,8 +1,8 @@
# Legado with MD3 # Legado with MD3
<p align="left"> <p align="left">
<a href="https://github.com/HapeLee/legado-with-MD3">简体中文</a> <a href="https://gitea.yamby.cn/yusheng/QieKan-3.0">简体中文</a>
<a href="https://github.com/HapeLee/legado-with-MD3/blob/main/English.md">English</a> <a href="https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/English.md">English</a>
</p> </p>
--- ---
+3 -3
View File
@@ -1,8 +1,8 @@
# Legado with MD3 # Legado with MD3
<p align="left"> <p align="left">
<a href="https://github.com/HapeLee/legado-with-MD3">简体中文</a> <a href="https://gitea.yamby.cn/yusheng/QieKan-3.0">简体中文</a>
<a href="https://github.com/HapeLee/legado-with-MD3/blob/main/English.md">English</a> <a href="https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/English.md">English</a>
</p> </p>
## 📖 介绍 ## 📖 介绍
@@ -50,7 +50,7 @@ Design 3 风格重构版本。
* **Telegram 群组:** [Legado with MD3](https://t.me/materado) * **Telegram 群组:** [Legado with MD3](https://t.me/materado)
* **版本发布:** * **版本发布:**
最新版本会自动发布至群组,也可在 [GitHub Releases](https://github.com/HapeLee/legado-with-MD3/releases) 最新版本会自动发布至群组,也可在 [Gitea Releases](https://gitea.yamby.cn/yusheng/QieKan-3.0/releases)
下载。 下载。
--- ---
+2 -1
View File
@@ -51,7 +51,7 @@ android {
applicationId = "io.legato.kazusa" applicationId = "io.legato.kazusa"
minSdk = 26 minSdk = 26
targetSdk = 37 targetSdk = 37
versionCode = System.getenv("COMMIT_NUMBER")?.toInt()?.let { 10000 + it } ?: 32641 versionCode = System.getenv("COMMIT_NUMBER")?.toInt()?.let { 10000 + it } ?: 32642
versionName = System.getenv("APP_VERSION_NAME") ?: projectVersionName versionName = System.getenv("APP_VERSION_NAME") ?: projectVersionName
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -225,6 +225,7 @@ dependencies {
implementation(libs.jsoupxpath) implementation(libs.jsoupxpath)
implementation(libs.intellij.markdown) implementation(libs.intellij.markdown)
implementation(project(":modules:book")) implementation(project(":modules:book"))
implementation(project(":modules:audiobookshelf-client"))
implementation(project(":modules:rhino")) implementation(project(":modules:rhino"))
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(fileTree(mapOf("dir" to "cronetlib", "include" to listOf("*.jar", "*.aar")))) implementation(fileTree(mapOf("dir" to "cronetlib", "include" to listOf("*.jar", "*.aar"))))
@@ -3,8 +3,8 @@ package io.legado.app
import com.google.gson.Gson import com.google.gson.Gson
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.http.okHttpClient import io.legado.app.help.http.okHttpClient
import io.legado.app.help.update.GithubRelease import io.legado.app.help.update.GiteaRelease
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonArray
import okhttp3.Request import okhttp3.Request
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
@@ -12,24 +12,25 @@ import org.junit.Test
class UpdateTest { class UpdateTest {
private val lastReleaseUrl = private val lastReleaseUrl =
"https://api.github.com/repos/gedoor/legado/releases/latest" "https://gitea.yamby.cn/api/v1/repos/yusheng/QieKan-3.0/releases"
private val lastBetaReleaseUrl = private val lastBetaReleaseUrl =
"https://api.github.com/repos/gedoor/legado/releases/tags/beta" "https://gitea.yamby.cn/api/v1/repos/yusheng/QieKan-3.0/releases"
@Test @Test
fun updateApp_beta() { fun updateApp_beta() {
val body = okHttpClient.newCall(Request.Builder().url(lastBetaReleaseUrl).build()).execute() val body = okHttpClient.newCall(Request.Builder().url(lastBetaReleaseUrl).build()).execute()
.body!!.string() .body!!.string()
val releaseList = Gson().fromJsonObject<GithubRelease>(body) val releaseList = Gson().fromJsonArray<GiteaRelease>(body)
.getOrElse { .getOrElse {
throw NoStackTraceException("获取新版本出错 " + it.localizedMessage) throw NoStackTraceException("获取新版本出错 " + it.localizedMessage)
} }
.gitReleaseToAppReleaseInfo() .filter { it.isPreRelease }
.flatMap { it.toAppReleaseInfo() }
.sortedByDescending { it.createdAt } .sortedByDescending { it.createdAt }
assertTrue(releaseList.size == 2) assertTrue(releaseList.isNotEmpty())
assertTrue(releaseList.all { it.downloadUrl.isNotBlank() }) assertTrue(releaseList.all { it.downloadUrl.isNotBlank() })
assertTrue(releaseList.all { it.versionName.isNotBlank() }) assertTrue(releaseList.all { it.versionName.isNotBlank() })
} }
@@ -39,16 +40,17 @@ class UpdateTest {
val body = okHttpClient.newCall(Request.Builder().url(lastReleaseUrl).build()).execute() val body = okHttpClient.newCall(Request.Builder().url(lastReleaseUrl).build()).execute()
.body!!.string() .body!!.string()
val releaseList = Gson().fromJsonObject<GithubRelease>(body) val releaseList = Gson().fromJsonArray<GiteaRelease>(body)
.getOrElse { .getOrElse {
throw NoStackTraceException("获取新版本出错 " + it.localizedMessage) throw NoStackTraceException("获取新版本出错 " + it.localizedMessage)
} }
.gitReleaseToAppReleaseInfo() .filter { !it.isPreRelease }
.flatMap { it.toAppReleaseInfo() }
.sortedByDescending { it.createdAt } .sortedByDescending { it.createdAt }
assertTrue(releaseList.size == 1) assertTrue(releaseList.isNotEmpty())
assertTrue(releaseList.all { it.downloadUrl.isNotBlank() }) assertTrue(releaseList.all { it.downloadUrl.isNotBlank() })
assertTrue(releaseList.all { it.versionName.isNotBlank() }) assertTrue(releaseList.all { it.versionName.isNotBlank() })
} }
} }
@@ -22576,17 +22576,17 @@ PLio/legado/app/help/storage/BackupConfig$$ExternalSyntheticLambda0;->invoke()Lj
PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V
Lio/legado/app/help/update/AppUpdate$AppUpdateInterface; Lio/legado/app/help/update/AppUpdate$AppUpdateInterface;
Lio/legado/app/help/update/AppUpdate$UpdateInfo; Lio/legado/app/help/update/AppUpdate$UpdateInfo;
Lio/legado/app/help/update/AppUpdateGitHub; Lio/legado/app/help/update/AppUpdateGitea;
HSPLio/legado/app/help/update/AppUpdateGitHub;-><clinit>()V HSPLio/legado/app/help/update/AppUpdateGitea;-><clinit>()V
HSPLio/legado/app/help/update/AppUpdateGitHub;-><init>()V HSPLio/legado/app/help/update/AppUpdateGitea;-><init>()V
HSPLio/legado/app/help/update/AppUpdateGitHub;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit; HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit;
HSPLio/legado/app/help/update/AppUpdateGitHub;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Lio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0; Lio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;
HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;-><init>(Ljava/lang/String;)V HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;-><init>(Ljava/lang/String;)V
HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
Lio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1; Lio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;
HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;-><init>(Lio/legado/app/help/update/AppUpdateGitHub;Lkotlin/coroutines/Continuation;)V HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;-><init>(Lio/legado/app/help/update/AppUpdateGitea;Lkotlin/coroutines/Continuation;)V
HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
Lio/legado/app/help/update/AppVariant; Lio/legado/app/help/update/AppVariant;
HSPLio/legado/app/help/update/AppVariant;->$values()[Lio/legado/app/help/update/AppVariant; HSPLio/legado/app/help/update/AppVariant;->$values()[Lio/legado/app/help/update/AppVariant;
HSPLio/legado/app/help/update/AppVariant;-><clinit>()V HSPLio/legado/app/help/update/AppVariant;-><clinit>()V
@@ -31483,4 +31483,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle; Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle; HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V
@@ -22576,17 +22576,17 @@ PLio/legado/app/help/storage/BackupConfig$$ExternalSyntheticLambda0;->invoke()Lj
PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V
Lio/legado/app/help/update/AppUpdate$AppUpdateInterface; Lio/legado/app/help/update/AppUpdate$AppUpdateInterface;
Lio/legado/app/help/update/AppUpdate$UpdateInfo; Lio/legado/app/help/update/AppUpdate$UpdateInfo;
Lio/legado/app/help/update/AppUpdateGitHub; Lio/legado/app/help/update/AppUpdateGitea;
HSPLio/legado/app/help/update/AppUpdateGitHub;-><clinit>()V HSPLio/legado/app/help/update/AppUpdateGitea;-><clinit>()V
HSPLio/legado/app/help/update/AppUpdateGitHub;-><init>()V HSPLio/legado/app/help/update/AppUpdateGitea;-><init>()V
HSPLio/legado/app/help/update/AppUpdateGitHub;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit; HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit;
HSPLio/legado/app/help/update/AppUpdateGitHub;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Lio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0; Lio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;
HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;-><init>(Ljava/lang/String;)V HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;-><init>(Ljava/lang/String;)V
HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
Lio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1; Lio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;
HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;-><init>(Lio/legado/app/help/update/AppUpdateGitHub;Lkotlin/coroutines/Continuation;)V HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;-><init>(Lio/legado/app/help/update/AppUpdateGitea;Lkotlin/coroutines/Continuation;)V
HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
Lio/legado/app/help/update/AppVariant; Lio/legado/app/help/update/AppVariant;
HSPLio/legado/app/help/update/AppVariant;->$values()[Lio/legado/app/help/update/AppVariant; HSPLio/legado/app/help/update/AppVariant;->$values()[Lio/legado/app/help/update/AppVariant;
HSPLio/legado/app/help/update/AppVariant;-><clinit>()V HSPLio/legado/app/help/update/AppVariant;-><clinit>()V
@@ -31483,4 +31483,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle; Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle; HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V
@@ -22576,17 +22576,17 @@ PLio/legado/app/help/storage/BackupConfig$$ExternalSyntheticLambda0;->invoke()Lj
PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V
Lio/legado/app/help/update/AppUpdate$AppUpdateInterface; Lio/legado/app/help/update/AppUpdate$AppUpdateInterface;
Lio/legado/app/help/update/AppUpdate$UpdateInfo; Lio/legado/app/help/update/AppUpdate$UpdateInfo;
Lio/legado/app/help/update/AppUpdateGitHub; Lio/legado/app/help/update/AppUpdateGitea;
HSPLio/legado/app/help/update/AppUpdateGitHub;-><clinit>()V HSPLio/legado/app/help/update/AppUpdateGitea;-><clinit>()V
HSPLio/legado/app/help/update/AppUpdateGitHub;-><init>()V HSPLio/legado/app/help/update/AppUpdateGitea;-><init>()V
HSPLio/legado/app/help/update/AppUpdateGitHub;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit; HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit;
HSPLio/legado/app/help/update/AppUpdateGitHub;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Lio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0; Lio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;
HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;-><init>(Ljava/lang/String;)V HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;-><init>(Ljava/lang/String;)V
HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
Lio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1; Lio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;
HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;-><init>(Lio/legado/app/help/update/AppUpdateGitHub;Lkotlin/coroutines/Continuation;)V HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;-><init>(Lio/legado/app/help/update/AppUpdateGitea;Lkotlin/coroutines/Continuation;)V
HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
Lio/legado/app/help/update/AppVariant; Lio/legado/app/help/update/AppVariant;
HSPLio/legado/app/help/update/AppVariant;->$values()[Lio/legado/app/help/update/AppVariant; HSPLio/legado/app/help/update/AppVariant;->$values()[Lio/legado/app/help/update/AppVariant;
HSPLio/legado/app/help/update/AppVariant;-><clinit>()V HSPLio/legado/app/help/update/AppVariant;-><clinit>()V
@@ -31483,4 +31483,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle; Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle; HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V
@@ -22576,17 +22576,17 @@ PLio/legado/app/help/storage/BackupConfig$$ExternalSyntheticLambda0;->invoke()Lj
PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V
Lio/legado/app/help/update/AppUpdate$AppUpdateInterface; Lio/legado/app/help/update/AppUpdate$AppUpdateInterface;
Lio/legado/app/help/update/AppUpdate$UpdateInfo; Lio/legado/app/help/update/AppUpdate$UpdateInfo;
Lio/legado/app/help/update/AppUpdateGitHub; Lio/legado/app/help/update/AppUpdateGitea;
HSPLio/legado/app/help/update/AppUpdateGitHub;-><clinit>()V HSPLio/legado/app/help/update/AppUpdateGitea;-><clinit>()V
HSPLio/legado/app/help/update/AppUpdateGitHub;-><init>()V HSPLio/legado/app/help/update/AppUpdateGitea;-><init>()V
HSPLio/legado/app/help/update/AppUpdateGitHub;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit; HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit;
HSPLio/legado/app/help/update/AppUpdateGitHub;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
Lio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0; Lio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;
HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;-><init>(Ljava/lang/String;)V HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;-><init>(Ljava/lang/String;)V
HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
Lio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1; Lio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;
HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;-><init>(Lio/legado/app/help/update/AppUpdateGitHub;Lkotlin/coroutines/Continuation;)V HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;-><init>(Lio/legado/app/help/update/AppUpdateGitea;Lkotlin/coroutines/Continuation;)V
HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object; HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
Lio/legado/app/help/update/AppVariant; Lio/legado/app/help/update/AppVariant;
HSPLio/legado/app/help/update/AppVariant;->$values()[Lio/legado/app/help/update/AppVariant; HSPLio/legado/app/help/update/AppVariant;->$values()[Lio/legado/app/help/update/AppVariant;
HSPLio/legado/app/help/update/AppVariant;-><clinit>()V HSPLio/legado/app/help/update/AppVariant;-><clinit>()V
@@ -31483,4 +31483,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle; Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle; HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->$values()[Ltop/yukonga/miuix/kmp/theme/ThemePaletteStyle;
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><clinit>()V
HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;-><init>(Ljava/lang/String;I)V
@@ -264,6 +264,8 @@ object PreferKey {
const val paddingDisplayCutouts = "paddingDisplayCutouts" const val paddingDisplayCutouts = "paddingDisplayCutouts"
const val autoCheckNewBackup = "autoCheckNewBackup" const val autoCheckNewBackup = "autoCheckNewBackup"
const val backupSyncMode = "backupSyncMode" const val backupSyncMode = "backupSyncMode"
const val backupFonts = "backupFonts"
const val backupLocalBooks = "backupLocalBooks"
const val bookshelfLayoutGridLandscape = "bookshelfLayoutGridLandscape" const val bookshelfLayoutGridLandscape = "bookshelfLayoutGridLandscape"
const val bookshelfLayoutGridPortrait = "bookshelfLayoutGridPortrait" const val bookshelfLayoutGridPortrait = "bookshelfLayoutGridPortrait"
const val bookshelfLayoutListLandscape = "bookshelfLayoutListLandscape" const val bookshelfLayoutListLandscape = "bookshelfLayoutListLandscape"
@@ -0,0 +1,179 @@
package io.legado.app.data.audiobook
import io.legado.audiobookshelf.client.AudiobookshelfApi
import io.legado.audiobookshelf.client.AudiobookshelfApiException
import io.legado.app.data.dao.ServerDao
import io.legado.app.data.entities.Server
import io.legado.app.domain.gateway.AudiobookAuthenticationRequiredException
import io.legado.app.domain.gateway.AudiobookGateway
import io.legado.app.domain.model.AudiobookHomeSnapshot
import io.legado.app.domain.model.AudiobookLibrarySummary
import io.legado.app.domain.model.AudiobookServerProfile
import io.legado.app.utils.GSON
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
class AudiobookRepository(
private val serverDao: ServerDao,
private val api: AudiobookshelfApi,
private val tokenStore: AudiobookTokenStore
) : AudiobookGateway {
private val accessTokens = ConcurrentHashMap<Long, String>()
private val refreshMutex = Mutex()
override suspend fun getSavedProfile(): AudiobookServerProfile? = withContext(Dispatchers.IO) {
serverDao.all.firstNotNullOfOrNull { server -> server.toAudiobookProfileOrNull() }
}
override suspend fun connect(
existingProfileId: Long?,
name: String,
baseUrl: String,
username: String,
password: String
): AudiobookHomeSnapshot = withContext(Dispatchers.IO) {
require(username.isNotBlank()) { "请输入用户名" }
require(password.isNotBlank()) { "请输入密码" }
val normalizedBaseUrl = AudiobookshelfApi.normalizeBaseUrl(baseUrl)
val status = api.getStatus(normalizedBaseUrl)
require(status.initialized) { "Audiobookshelf 服务尚未完成初始化" }
val auth = api.login(
baseUrl = normalizedBaseUrl,
username = username.trim(),
password = password
)
val libraries = api.getLibraries(normalizedBaseUrl, auth.accessToken)
val existingServer = existingProfileId
?.let(serverDao::get)
?.takeIf { it.type == Server.TYPE.AUDIOBOOKSHELF }
val profileId = existingServer?.id ?: System.currentTimeMillis()
val profileName = name.trim().ifEmpty { "Audiobookshelf" }
val savedUsername = auth.username.ifBlank { username.trim() }
val server = Server(
id = profileId,
name = profileName,
type = Server.TYPE.AUDIOBOOKSHELF,
config = GSON.toJson(
Server.AudiobookshelfConfig(
url = normalizedBaseUrl,
username = savedUsername
)
),
sortNumber = existingServer?.sortNumber ?: nextSortNumber()
)
tokenStore.saveRefreshToken(profileId, auth.refreshToken)
serverDao.insert(server)
accessTokens[profileId] = auth.accessToken
buildSnapshot(server.toAudiobookProfile(), status.version, libraries)
}
override suspend fun restore(profileId: Long): AudiobookHomeSnapshot {
return refresh(profileId)
}
override suspend fun refresh(profileId: Long): AudiobookHomeSnapshot = withContext(Dispatchers.IO) {
val profile = requireProfile(profileId)
val status = api.getStatus(profile.baseUrl)
var accessToken = accessTokens[profileId]
?: refreshAccessToken(profileId, profile.baseUrl)
val libraries = try {
api.getLibraries(profile.baseUrl, accessToken)
} catch (error: AudiobookshelfApiException) {
if (error.statusCode != 401) throw error
accessToken = refreshAccessToken(
profileId = profileId,
baseUrl = profile.baseUrl,
rejectedAccessToken = accessToken
)
api.getLibraries(profile.baseUrl, accessToken)
}
buildSnapshot(profile, status.version, libraries)
}
override suspend fun forget(profileId: Long) = withContext(Dispatchers.IO) {
serverDao.get(profileId)
?.takeIf { it.type == Server.TYPE.AUDIOBOOKSHELF }
?.let(serverDao::delete)
accessTokens.remove(profileId)
tokenStore.delete(profileId)
}
private suspend fun refreshAccessToken(
profileId: Long,
baseUrl: String,
rejectedAccessToken: String? = null
): String = refreshMutex.withLock {
val current = accessTokens[profileId]
if (current != null && current != rejectedAccessToken) return@withLock current
val refreshToken = tokenStore.readRefreshToken(profileId)
?: throw AudiobookAuthenticationRequiredException()
try {
val auth = api.refresh(baseUrl, refreshToken)
tokenStore.saveRefreshToken(profileId, auth.refreshToken)
accessTokens[profileId] = auth.accessToken
auth.accessToken
} catch (error: AudiobookshelfApiException) {
if (error.statusCode == 401) {
tokenStore.delete(profileId)
accessTokens.remove(profileId)
throw AudiobookAuthenticationRequiredException()
}
throw error
}
}
private fun requireProfile(profileId: Long): AudiobookServerProfile {
return serverDao.get(profileId)?.toAudiobookProfileOrNull()
?: throw IllegalArgumentException("未找到有声书服务器配置")
}
private fun nextSortNumber(): Int {
return (serverDao.all.maxOfOrNull(Server::sortNumber) ?: -1) + 1
}
private fun Server.toAudiobookProfileOrNull(): AudiobookServerProfile? {
if (type != Server.TYPE.AUDIOBOOKSHELF) return null
return getAudiobookshelfConfig()?.let { config ->
AudiobookServerProfile(
id = id,
name = name,
baseUrl = config.url,
username = config.username
)
}
}
private fun Server.toAudiobookProfile(): AudiobookServerProfile {
return requireNotNull(toAudiobookProfileOrNull())
}
private fun buildSnapshot(
profile: AudiobookServerProfile,
serverVersion: String,
libraries: List<io.legado.audiobookshelf.client.AudiobookshelfLibrary>
): AudiobookHomeSnapshot {
return AudiobookHomeSnapshot(
profile = profile,
serverVersion = serverVersion,
libraries = libraries.map { library ->
AudiobookLibrarySummary(
id = library.id,
name = library.name,
mediaType = library.mediaType,
icon = library.icon
)
}
)
}
}
@@ -0,0 +1,89 @@
package io.legado.app.data.audiobook
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.io.File
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
class AudiobookTokenStore(context: Context) {
private val tokenDirectory = File(context.noBackupFilesDir, TOKEN_DIRECTORY)
private val keyAlias = "${context.packageName}.audiobookshelf.refresh"
fun saveRefreshToken(profileId: Long, refreshToken: String) {
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey())
}
val encrypted = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
tokenDirectory.mkdirs()
tokenFile(profileId).writeText(
buildString {
append(Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
append('\n')
append(Base64.encodeToString(encrypted, Base64.NO_WRAP))
},
Charsets.UTF_8
)
}
fun readRefreshToken(profileId: Long): String? {
val file = tokenFile(profileId)
if (!file.isFile) return null
return runCatching {
val lines = file.readLines(Charsets.UTF_8)
require(lines.size == 2)
val iv = Base64.decode(lines[0], Base64.NO_WRAP)
val encrypted = Base64.decode(lines[1], Base64.NO_WRAP)
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
init(
Cipher.DECRYPT_MODE,
getOrCreateSecretKey(),
GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)
)
}
cipher.doFinal(encrypted).toString(Charsets.UTF_8)
}.getOrNull()
}
fun delete(profileId: Long) {
tokenFile(profileId).delete()
}
private fun tokenFile(profileId: Long): File {
return File(tokenDirectory, "$profileId.token")
}
private fun getOrCreateSecretKey(): SecretKey {
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(
KeyProperties.KEY_ALGORITHM_AES,
ANDROID_KEY_STORE
)
generator.init(
KeyGenParameterSpec.Builder(
keyAlias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setRandomizedEncryptionRequired(true)
.build()
)
return generator.generateKey()
}
private companion object {
const val TOKEN_DIRECTORY = "audiobookshelf"
const val ANDROID_KEY_STORE = "AndroidKeyStore"
const val TRANSFORMATION = "AES/GCM/NoPadding"
const val GCM_TAG_LENGTH_BITS = 128
}
}
@@ -23,7 +23,8 @@ data class Server(
) : Parcelable { ) : Parcelable {
enum class TYPE { enum class TYPE {
WEBDAV WEBDAV,
AUDIOBOOKSHELF
} }
override fun hashCode(): Int { override fun hashCode(): Int {
@@ -47,6 +48,14 @@ data class Server(
return if (type == TYPE.WEBDAV) GSON.fromJsonObject<WebDavConfig>(config).getOrNull() else null return if (type == TYPE.WEBDAV) GSON.fromJsonObject<WebDavConfig>(config).getOrNull() else null
} }
fun getAudiobookshelfConfig(): AudiobookshelfConfig? {
return if (type == TYPE.AUDIOBOOKSHELF) {
GSON.fromJsonObject<AudiobookshelfConfig>(config).getOrNull()
} else {
null
}
}
@Parcelize @Parcelize
data class WebDavConfig( data class WebDavConfig(
var url: String, var url: String,
@@ -54,4 +63,10 @@ data class Server(
var password: String var password: String
) : Parcelable ) : Parcelable
} @Parcelize
data class AudiobookshelfConfig(
var url: String,
var username: String
) : Parcelable
}
@@ -18,6 +18,7 @@ import io.legado.app.model.remote.RemoteBook
import io.legado.app.model.remote.RemoteBookWebDav import io.legado.app.model.remote.RemoteBookWebDav
import io.legado.app.utils.isContentScheme import io.legado.app.utils.isContentScheme
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
class RemoteBookRepository( class RemoteBookRepository(
private val appDb: AppDatabase private val appDb: AppDatabase
@@ -126,7 +127,9 @@ class RemoteBookRepository(
} }
fun flowServers(): Flow<List<Server>> { fun flowServers(): Flow<List<Server>> {
return appDb.serverDao.observeAll() return appDb.serverDao.observeAll().map { servers ->
servers.filter { it.type == Server.TYPE.WEBDAV }
}
} }
suspend fun getServer(id: Long): Server? { suspend fun getServer(id: Long): Server? {
@@ -5,6 +5,9 @@ import coil.ImageLoader
import coil.decode.GifDecoder import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder import coil.decode.ImageDecoderDecoder
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import io.legado.audiobookshelf.client.AudiobookshelfApi
import io.legado.app.data.audiobook.AudiobookRepository
import io.legado.app.data.audiobook.AudiobookTokenStore
import io.legado.app.data.AppDatabase import io.legado.app.data.AppDatabase
import io.legado.app.data.local.preferences.LocalPreferencesRepository import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.data.repository.AiArtifactRepository import io.legado.app.data.repository.AiArtifactRepository
@@ -56,6 +59,7 @@ import io.legado.app.domain.gateway.AiMemoryGateway
import io.legado.app.domain.gateway.AiProfileGateway import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.gateway.AiTextGateway import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.gateway.AiToolGateway import io.legado.app.domain.gateway.AiToolGateway
import io.legado.app.domain.gateway.AudiobookGateway
import io.legado.app.domain.gateway.AppStartupGateway import io.legado.app.domain.gateway.AppStartupGateway
import io.legado.app.domain.gateway.BackupRestoreGateway import io.legado.app.domain.gateway.BackupRestoreGateway
import io.legado.app.domain.gateway.BookCacheCleanupGateway import io.legado.app.domain.gateway.BookCacheCleanupGateway
@@ -111,6 +115,7 @@ import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.okHttpClientManga import io.legado.app.help.http.okHttpClientManga
import io.legado.app.ui.about.AboutViewModel import io.legado.app.ui.about.AboutViewModel
import io.legado.app.ui.ai.chat.AiChatViewModel import io.legado.app.ui.ai.chat.AiChatViewModel
import io.legado.app.ui.audiobook.AudiobookViewModel
import io.legado.app.ui.book.bookmark.AllBookmarkViewModel import io.legado.app.ui.book.bookmark.AllBookmarkViewModel
import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel import io.legado.app.ui.book.cache.manage.BookCacheManageViewModel
import io.legado.app.ui.book.changecover.ChangeCoverViewModel import io.legado.app.ui.book.changecover.ChangeCoverViewModel
@@ -227,6 +232,10 @@ val appModule = module {
singleOf(::BookshelfManageScreenConfig) singleOf(::BookshelfManageScreenConfig)
singleOf(::ThemePackageManager) singleOf(::ThemePackageManager)
single { AudiobookshelfApi() }
single { AudiobookTokenStore(get()) }
single<AudiobookGateway> { AudiobookRepository(get(), get(), get()) }
single<UploadRepository> { DirectLinkUploadRepository() } single<UploadRepository> { DirectLinkUploadRepository() }
single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() } single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
single<AiProfileGateway> { AiProfileRepository(get()) } single<AiProfileGateway> { AiProfileRepository(get()) }
@@ -315,6 +324,7 @@ val appModule = module {
viewModelOf(::BackupConfigViewModel) viewModelOf(::BackupConfigViewModel)
viewModelOf(::AiConfigViewModel) viewModelOf(::AiConfigViewModel)
viewModelOf(::AiChatViewModel) viewModelOf(::AiChatViewModel)
viewModelOf(::AudiobookViewModel)
viewModel { (providerId: String?) -> viewModel { (providerId: String?) ->
AiProviderEditViewModel( AiProviderEditViewModel(
initialProviderId = providerId, initialProviderId = providerId,
@@ -0,0 +1,27 @@
package io.legado.app.domain.gateway
import io.legado.app.domain.model.AudiobookHomeSnapshot
import io.legado.app.domain.model.AudiobookServerProfile
interface AudiobookGateway {
suspend fun getSavedProfile(): AudiobookServerProfile?
suspend fun connect(
existingProfileId: Long?,
name: String,
baseUrl: String,
username: String,
password: String
): AudiobookHomeSnapshot
suspend fun restore(profileId: Long): AudiobookHomeSnapshot
suspend fun refresh(profileId: Long): AudiobookHomeSnapshot
suspend fun forget(profileId: Long)
}
class AudiobookAuthenticationRequiredException(
message: String = "登录已失效,请重新输入密码"
) : Exception(message)
@@ -0,0 +1,21 @@
package io.legado.app.domain.model
data class AudiobookServerProfile(
val id: Long,
val name: String,
val baseUrl: String,
val username: String
)
data class AudiobookLibrarySummary(
val id: String,
val name: String,
val mediaType: String,
val icon: String?
)
data class AudiobookHomeSnapshot(
val profile: AudiobookServerProfile,
val serverVersion: String,
val libraries: List<AudiobookLibrarySummary>
)
@@ -198,6 +198,7 @@ fun Book.getLocalUri(): Uri {
val newBook = oldBook.copy(bookUrl = newBookUrl) val newBook = oldBook.copy(bookUrl = newBookUrl)
appDb.bookDao.replace(oldBook, newBook) appDb.bookDao.replace(oldBook, newBook)
BookHelp.updateCacheFolder(oldBook, newBook) BookHelp.updateCacheFolder(oldBook, newBook)
appDb.readRecordDao.replaceBookUrl(oldBook.bookUrl, newBookUrl)
this.bookUrl = newBookUrl this.bookUrl = newBookUrl
} }
} }
@@ -230,6 +231,7 @@ fun Book.getLocalUri(): Uri {
val newBook = oldBook.copy(bookUrl = newBookUrl) val newBook = oldBook.copy(bookUrl = newBookUrl)
appDb.bookDao.replace(oldBook, newBook) appDb.bookDao.replace(oldBook, newBook)
BookHelp.updateCacheFolder(oldBook, newBook) BookHelp.updateCacheFolder(oldBook, newBook)
appDb.readRecordDao.replaceBookUrl(oldBook.bookUrl, newBookUrl)
this.bookUrl = newBookUrl this.bookUrl = newBookUrl
} }
} }
@@ -244,13 +246,22 @@ fun Book.getLocalUri(): Uri {
fun Book.getArchiveUri(): Uri? { fun Book.getArchiveUri(): Uri? {
val defaultBookDir = OtherConfig.defaultBookTreeUri if (!isArchive) return null
return if (isArchive && !defaultBookDir.isNullOrBlank()) { val archiveFileName = archiveName
FileDoc.fromUri(defaultBookDir.toUri(), true) val searchDirs = listOfNotNull(
.find(archiveName)?.uri OtherConfig.defaultBookTreeUri,
} else { AppConfig.importBookPath
null ).filter { it.isNotBlank() }.distinct()
searchDirs.forEach { dir ->
val fileDoc = runCatching {
FileDoc.fromUri(dir.toUri(), true)
.find(archiveFileName, depth = 5, maxFinds = 100)
}.getOrNull()
if (fileDoc != null) {
return fileDoc.uri
}
} }
return null
} }
fun Book.cacheLocalUri(uri: Uri) { fun Book.cacheLocalUri(uri: Uri) {
@@ -565,6 +565,10 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val onlyLatestBackup get() = BackupConfig.onlyLatestBackup val onlyLatestBackup get() = BackupConfig.onlyLatestBackup
val backupFonts get() = BackupConfig.backupFonts
val backupLocalBooks get() = BackupConfig.backupLocalBooks
val autoCheckNewBackup get() = ThemeConfig.autoCheckNewBackup val autoCheckNewBackup get() = ThemeConfig.autoCheckNewBackup
val defaultHomePage get() = ThemeConfig.defaultHomePage val defaultHomePage get() = ThemeConfig.defaultHomePage
@@ -15,18 +15,26 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.ThemeConfigStore import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.help.book.isLocal
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.data.repository.HighlightRuleRepository
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.LogUtils import io.legado.app.utils.LogUtils
import io.legado.app.utils.MD5Utils
import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFolderIfNotExist import io.legado.app.utils.createFolderIfNotExist
import io.legado.app.utils.defaultSharedPreferences
import io.legado.app.data.repository.dataStore import io.legado.app.data.repository.dataStore
import io.legado.app.utils.externalFiles import io.legado.app.utils.externalFiles
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import io.legado.app.utils.getFile import io.legado.app.utils.getFile
import io.legado.app.utils.isContentScheme import io.legado.app.utils.isContentScheme
import io.legado.app.utils.inputStream
import io.legado.app.utils.normalizeFileName import io.legado.app.utils.normalizeFileName
import io.legado.app.utils.openOutputStream import io.legado.app.utils.openOutputStream
import io.legado.app.utils.outputStream import io.legado.app.utils.outputStream
@@ -56,6 +64,11 @@ object Backup {
private const val TAG = "Backup" private const val TAG = "Backup"
const val fontsDirName = "fonts"
const val localBooksDirName = "localBooks"
const val fontMapFileName = "fonts.json"
const val localBooksMapFileName = "localBooks.json"
private val backupFileNames by lazy { private val backupFileNames by lazy {
arrayOf( arrayOf(
"bookshelf.json", "bookshelf.json",
@@ -65,6 +78,7 @@ object Backup {
"rssSources.json", "rssSources.json",
"rssStar.json", "rssStar.json",
"replaceRule.json", "replaceRule.json",
HighlightRuleRepository.backupFileName,
"readRecord.json", "readRecord.json",
"readRecordDetail.json", "readRecordDetail.json",
"readRecordSession.json", "readRecordSession.json",
@@ -132,6 +146,14 @@ object Backup {
LocalConfig.lastBackup = System.currentTimeMillis() LocalConfig.lastBackup = System.currentTimeMillis()
val aes = BackupAES() val aes = BackupAES()
FileUtils.delete(backupPath) FileUtils.delete(backupPath)
//打包自定义字体与本地书籍(在写入书架前执行,本地书籍查找会同步更新书架中的路径)
val extraBackupPaths = arrayListOf<String>()
if (AppConfig.backupFonts) {
backupFontsTo(File(backupPath))?.let { extraBackupPaths.add(it) }
}
if (AppConfig.backupLocalBooks) {
backupLocalBooksTo(File(backupPath))?.let { extraBackupPaths.add(it) }
}
writeListToJson(appDb.bookDao.all, "bookshelf.json", backupPath) writeListToJson(appDb.bookDao.all, "bookshelf.json", backupPath)
writeListToJson(appDb.bookmarkDao.all, "bookmark.json", backupPath) writeListToJson(appDb.bookmarkDao.all, "bookmark.json", backupPath)
writeListToJson(appDb.bookGroupDao.all, "bookGroup.json", backupPath) writeListToJson(appDb.bookGroupDao.all, "bookGroup.json", backupPath)
@@ -139,6 +161,24 @@ object Backup {
writeListToJson(appDb.rssSourceDao.all, "rssSources.json", backupPath) writeListToJson(appDb.rssSourceDao.all, "rssSources.json", backupPath)
writeListToJson(appDb.rssStarDao.all, "rssStar.json", backupPath) writeListToJson(appDb.rssStarDao.all, "rssStar.json", backupPath)
writeListToJson(appDb.replaceRuleDao.all, "replaceRule.json", backupPath) writeListToJson(appDb.replaceRuleDao.all, "replaceRule.json", backupPath)
val highlightRuleBackup = HighlightRuleRepository.BackupData(
rules = appDb.highlightRuleDao.getAll(),
dialogEnabled = appCtx.defaultSharedPreferences.getBoolean(
PreferKey.highlightRuleDialog,
true
),
bookTitleEnabled = appCtx.defaultSharedPreferences.getBoolean(
PreferKey.highlightRuleBookTitle,
true
),
bracketNoteEnabled = appCtx.defaultSharedPreferences.getBoolean(
PreferKey.highlightRuleBracketNote,
true
)
)
FileUtils.createFileIfNotExist(
backupPath + File.separator + HighlightRuleRepository.backupFileName
).writeText(GSON.toJson(highlightRuleBackup))
writeListToJson(appDb.readRecordDao.all, "readRecord.json", backupPath) writeListToJson(appDb.readRecordDao.all, "readRecord.json", backupPath)
writeListToJson(appDb.readRecordDao.allDetail, "readRecordDetail.json", backupPath) writeListToJson(appDb.readRecordDao.allDetail, "readRecordDetail.json", backupPath)
writeListToJson(appDb.readRecordDao.allSession, "readRecordSession.json", backupPath) writeListToJson(appDb.readRecordDao.allSession, "readRecordSession.json", backupPath)
@@ -209,6 +249,7 @@ object Backup {
for (i in 0 until paths.size) { for (i in 0 until paths.size) {
paths[i] = backupPath + File.separator + paths[i] paths[i] = backupPath + File.separator + paths[i]
} }
paths.addAll(extraBackupPaths)
FileUtils.delete(zipFilePath) FileUtils.delete(zipFilePath)
FileUtils.delete(zipFilePath.replace("tmp_", "")) FileUtils.delete(zipFilePath.replace("tmp_", ""))
val backupFileName = if (AppConfig.onlyLatestBackup) { val backupFileName = if (AppConfig.onlyLatestBackup) {
@@ -270,6 +311,125 @@ object Backup {
} }
} }
/**
* 备份自定义字体, 返回打包目录路径
*/
private fun backupFontsTo(backupDir: File): String? = kotlin.runCatching {
val fontPathSet = hashSetOf<String>()
ReadBookConfig.configList.forEach { config ->
config.highlightRules.forEach { rule ->
rule.fontPath?.takeIf { it.isNotBlank() }?.let(fontPathSet::add)
}
listOf(config.textFont, config.titleFont, config.headerFont, config.footerFont)
.filter { it.isNotBlank() }
.forEach(fontPathSet::add)
}
listOf(
ReadBookConfig.shareConfig.textFont,
ReadBookConfig.shareConfig.titleFont,
ReadBookConfig.shareConfig.headerFont,
ReadBookConfig.shareConfig.footerFont
).filter { it.isNotBlank() }.forEach(fontPathSet::add)
ReadBookConfig.shareConfig.highlightRules.forEach { rule ->
rule.fontPath?.takeIf { it.isNotBlank() }?.let(fontPathSet::add)
}
appDb.highlightRuleDao.getAll().forEach { rule ->
rule.fontPath?.takeIf { it.isNotBlank() }?.let(fontPathSet::add)
}
ThemeConfig.appFontPath?.takeIf { it.isNotBlank() }?.let(fontPathSet::add)
val fontsDir = backupDir.getFile(fontsDirName)
//原始字体路径到备份文件名的映射, 恢复时用于重写字体路径
val fontPathMap = hashMapOf<String, String>()
var count = 0
var failed = 0
fontPathSet.forEach { fontPath ->
val sourceUri = fontPath.toUri()
val fileName = kotlin.runCatching {
FileDoc.fromUri(sourceUri, false).name
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return@forEach
val target = fontsDir.getFile(uniqueBackupFileName(fileName, fontPath))
val copied = kotlin.runCatching {
sourceUri.inputStream(appCtx).getOrThrow().use { input ->
fontsDir.mkdirs()
FileOutputStream(target, false).use { output ->
input.copyTo(output)
}
}
}.onFailure {
failed++
AppLog.put("备份字体 $fileName 出错\n${it.localizedMessage}", it)
}.isSuccess
if (copied) {
count++
fontPathMap[fontPath] = target.name
}
}
if (fontPathMap.isEmpty()) return@runCatching null
fontsDir.mkdirs()
fontsDir.getFile(fontMapFileName).writeText(GSON.toJson(fontPathMap))
if (failed > 0) {
AppLog.put("备份字体完成,成功 $count 个,跳过 $failed")
}
LogUtils.d(TAG, "备份字体 $count 个,跳过 $failed")
fontsDir.absolutePath
}.getOrElse {
AppLog.put("备份字体出错\n${it.localizedMessage}", it)
null
}
/**
* 备份本地书籍文件, 返回打包目录路径
*/
private fun backupLocalBooksTo(backupDir: File): String? = kotlin.runCatching {
val books = appDb.bookDao.all.filter { it.isLocal }
if (books.isEmpty()) return@runCatching null
val localBooksDir = backupDir.getFile(localBooksDirName)
val bookUrlMap = linkedMapOf<String, String>()
var count = 0
var failed = 0
books.forEach { book ->
val originalBookUrl = book.bookUrl
val originalName = book.originName.ifBlank { book.name }
val targetName = uniqueBackupFileName(originalName, originalBookUrl)
val copied = kotlin.runCatching {
//读取实际书籍内容。对于从压缩包导入的书籍,这里备份解压后的书籍文件,
//避免依赖源设备上的压缩包路径,恢复后可直接重绑定。
LocalBook.getBookInputStream(book).use { input ->
localBooksDir.mkdirs()
FileOutputStream(localBooksDir.getFile(targetName), false).use { output ->
input.copyTo(output)
}
count++
}
}.onFailure {
failed++
AppLog.put("备份本地书籍 ${book.name} 出错\n${it.localizedMessage}", it)
}.isSuccess
if (copied) {
// getBookInputStream 可能触发旧设备路径重绑,使用重绑后的 URL 写入映射。
bookUrlMap[book.bookUrl] = targetName
}
}
if (bookUrlMap.isEmpty()) return@runCatching null
localBooksDir.getFile(localBooksMapFileName).writeText(GSON.toJson(bookUrlMap))
if (failed > 0) {
AppLog.put("备份本地书籍完成,成功 $count 本,跳过 $failed")
}
LogUtils.d(TAG, "备份本地书籍 $count 本,跳过 $failed")
localBooksDir.absolutePath
}.getOrElse {
AppLog.put("备份本地书籍出错\n${it.localizedMessage}", it)
null
}
private fun uniqueBackupFileName(originalName: String, key: String): String {
val source = File(originalName)
val baseName = source.nameWithoutExtension.ifBlank { "resource" }
val extension = source.extension.takeIf { it.isNotBlank() }?.let { ".${it}" }.orEmpty()
return "${baseName}_${MD5Utils.md5Encode16(key)}$extension".normalizeFileName()
}
@Throws(Exception::class) @Throws(Exception::class)
@Suppress("SameParameterValue") @Suppress("SameParameterValue")
private fun copyBackup(context: Context, uri: Uri, fileName: String) { private fun copyBackup(context: Context, uri: Uri, fileName: String) {
@@ -79,10 +79,14 @@ object BackupConfig {
PreferKey.clickActionMR, PreferKey.clickActionMR,
PreferKey.clickActionBL, PreferKey.clickActionBL,
PreferKey.clickActionBC, PreferKey.clickActionBC,
PreferKey.clickActionBR PreferKey.clickActionBR,
PreferKey.highlightRuleDialog,
PreferKey.highlightRuleBookTitle,
PreferKey.highlightRuleBracketNote
) )
private val themePrefKeys = arrayOf( private val themePrefKeys = arrayOf(
PreferKey.appFontPath,
PreferKey.cPrimary, PreferKey.cPrimary,
PreferKey.cNPrimary, PreferKey.cNPrimary,
PreferKey.bgImage, PreferKey.bgImage,
@@ -28,9 +28,11 @@ import io.legado.app.data.entities.TxtTocRule
import io.legado.app.data.entities.readRecord.ReadRecord import io.legado.app.data.entities.readRecord.ReadRecord
import io.legado.app.data.entities.readRecord.ReadRecordDetail import io.legado.app.data.entities.readRecord.ReadRecordDetail
import io.legado.app.data.entities.readRecord.ReadRecordSession import io.legado.app.data.entities.readRecord.ReadRecordSession
import io.legado.app.data.repository.HighlightRuleRepository
import io.legado.app.data.repository.SettingsRepository import io.legado.app.data.repository.SettingsRepository
import io.legado.app.help.DirectLinkUpload import io.legado.app.help.DirectLinkUpload
import io.legado.app.help.LauncherIconHelp import io.legado.app.help.LauncherIconHelp
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.isLocal import io.legado.app.help.book.isLocal
import io.legado.app.help.book.upType import io.legado.app.help.book.upType
import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.LocalConfig
@@ -38,12 +40,18 @@ import io.legado.app.help.config.ThemeConfigStore
import io.legado.app.help.config.ReadBookConfig import io.legado.app.help.config.ReadBookConfig
import io.legado.app.model.BookCover import io.legado.app.model.BookCover
import io.legado.app.model.localBook.LocalBook import io.legado.app.model.localBook.LocalBook
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.config.themeConfig.ThemeConfig
import io.legado.app.utils.ACache import io.legado.app.utils.ACache
import io.legado.app.utils.FileDoc
import io.legado.app.utils.FileUtils import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import io.legado.app.utils.LogUtils import io.legado.app.utils.LogUtils
import io.legado.app.utils.compress.ZipUtils import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.defaultSharedPreferences import io.legado.app.utils.defaultSharedPreferences
import io.legado.app.utils.externalFiles
import io.legado.app.utils.fromJsonObject
import io.legado.app.utils.getFile
import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.getPrefString import io.legado.app.utils.getPrefString
import io.legado.app.utils.isContentScheme import io.legado.app.utils.isContentScheme
@@ -341,17 +349,45 @@ object Restore : KoinComponent {
} }
// 恢复配置文件 (手动解析 XML,替代反射逻辑) // 恢复配置文件 (手动解析 XML,替代反射逻辑)
val configFile = File(path, "config.xml") val configFile = File(path, "config.xml")
var restoredConfigMap: Map<String, Any?> = emptyMap()
if (configFile.exists()) { if (configFile.exists()) {
try { try {
val map = readXmlToMap(configFile) restoredConfigMap = readXmlToMap(configFile)
if (map.isNotEmpty()) { if (restoredConfigMap.isNotEmpty()) {
applyConfigMap(map, aes) applyConfigMap(restoredConfigMap, aes)
} }
} catch (e: Exception) { } catch (e: Exception) {
AppLog.put("恢复配置 XML 出错\n${e.localizedMessage}", e) AppLog.put("恢复配置 XML 出错\n${e.localizedMessage}", e)
} }
} }
if (!BackupConfig.ignoreReadConfig) {
kotlin.runCatching {
restoreHighlightRules(File(path, HighlightRuleRepository.backupFileName))
}.onFailure {
AppLog.put("恢复高亮规则出错\n${it.localizedMessage}", it)
}
}
//恢复自定义字体
kotlin.runCatching {
restoreFonts(
fontsDir = File(path, Backup.fontsDirName),
restoreReadFonts = !BackupConfig.ignoreReadConfig,
restoreThemeFont = !BackupConfig.ignoreThemeConfig,
backedAppFontPath = restoredConfigMap[PreferKey.appFontPath] as? String,
)
}.onFailure {
AppLog.put("恢复自定义字体出错\n${it.localizedMessage}", it)
}
//恢复本地书籍文件
if (!BackupConfig.ignoreLocalBook) {
kotlin.runCatching {
restoreLocalBooks(File(path, Backup.localBooksDirName))
}.onFailure {
AppLog.put("恢复本地书籍出错\n${it.localizedMessage}", it)
}
}
appCtx.toastOnUi(R.string.restore_success) appCtx.toastOnUi(R.string.restore_success)
withContext(Main) { withContext(Main) {
delay(100) delay(100)
@@ -362,6 +398,160 @@ object Restore : KoinComponent {
} }
} }
/**
* 恢复高亮规则及其开关配置。旧备份没有该文件时直接跳过。
*/
private fun restoreHighlightRules(file: File) {
if (!file.exists()) return
val backupData = GSON.fromJsonObject<HighlightRuleRepository.BackupData>(
file.readText()
).getOrNull() ?: return
appDb.highlightRuleDao.replaceAll(backupData.rules)
appCtx.defaultSharedPreferences.edit {
putBoolean(PreferKey.highlightRuleDialog, backupData.dialogEnabled)
putBoolean(PreferKey.highlightRuleBookTitle, backupData.bookTitleEnabled)
putBoolean(PreferKey.highlightRuleBracketNote, backupData.bracketNoteEnabled)
}
}
/**
* 恢复自定义字体文件, 并按映射重写排版配置中的字体路径。
* 阅读配置和主题配置分别遵守恢复忽略项,避免只恢复资源却修改了被忽略的配置。
*/
private fun restoreFonts(
fontsDir: File,
restoreReadFonts: Boolean,
restoreThemeFont: Boolean,
backedAppFontPath: String?,
) {
if (!fontsDir.exists() || (!restoreReadFonts && !restoreThemeFont)) return
val targetFontDir = appCtx.externalFiles.getFile("font")
targetFontDir.mkdirs()
fontsDir.listFiles()?.forEach { fontFile ->
if (fontFile.isFile && fontFile.name != Backup.fontMapFileName) {
// 用户已选择恢复字体,备份内容应覆盖目标设备同名旧字体。
fontFile.copyTo(targetFontDir.getFile(fontFile.name), overwrite = true)
}
}
//原始字体路径到备份文件名的映射
val fontMap = File(fontsDir, Backup.fontMapFileName)
.takeIf { it.exists() }
?.runCatching {
GSON.fromJsonObject<Map<String, String>>(readText()).getOrNull()
}?.getOrNull().orEmpty()
fun rewriteFont(fontPath: String?): String? {
if (fontPath.isNullOrBlank()) return fontPath
val fileName = fontMap[fontPath] ?: File(fontPath).name
if (fileName.isBlank()) return fontPath
val newFont = targetFontDir.getFile(fileName)
return if (newFont.exists()) newFont.absolutePath else fontPath
}
var changed = false
fun rewriteConfig(config: ReadBookConfig.Config) {
rewriteFont(config.textFont)?.let {
if (it != config.textFont) { config.textFont = it; changed = true }
}
rewriteFont(config.titleFont)?.let {
if (it != config.titleFont) { config.titleFont = it; changed = true }
}
rewriteFont(config.headerFont)?.let {
if (it != config.headerFont) { config.headerFont = it; changed = true }
}
rewriteFont(config.footerFont)?.let {
if (it != config.footerFont) { config.footerFont = it; changed = true }
}
config.highlightRules.forEach { rule ->
val newFontPath = rewriteFont(rule.fontPath)
if (newFontPath != rule.fontPath) { rule.fontPath = newFontPath; changed = true }
}
}
if (restoreReadFonts) {
ReadBookConfig.configList.forEach(::rewriteConfig)
rewriteConfig(ReadBookConfig.shareConfig)
// 高亮规则当前单独存储在 Room 中,不只存在于 ReadBookConfig JSON。
appDb.highlightRuleDao.getAll().forEach { rule ->
val newFontPath = rewriteFont(rule.fontPath)
if (newFontPath != rule.fontPath) {
appDb.highlightRuleDao.update(rule.copy(fontPath = newFontPath))
}
}
}
if (restoreThemeFont) {
// 直接使用备份 XML 中的值,避免 DataStore observer 尚未刷新时读取到旧 appFontPath。
val sourceAppFontPath = backedAppFontPath ?: ThemeConfig.appFontPath
val newAppFontPath = rewriteFont(sourceAppFontPath)
if (newAppFontPath != ThemeConfig.appFontPath) {
ThemeConfig.appFontPath = newAppFontPath
}
}
if (changed) {
ReadBookConfig.save()
}
}
/**
* 恢复本地书籍文件到书籍保存目录,并立即重绑书架及阅读记录中的 bookUrl。
* 新版备份使用 localBooks.json 映射;没有映射的旧备份仍按原文件名兼容恢复。
*/
private suspend fun restoreLocalBooks(localBooksDir: File) {
if (!localBooksDir.exists()) return
val defaultBookTreeUri = OtherConfig.defaultBookTreeUri
if (defaultBookTreeUri.isNullOrBlank()) {
appCtx.toastOnUi(R.string.no_books_dir)
return
}
val bookMap = File(localBooksDir, Backup.localBooksMapFileName)
.takeIf { it.exists() }
?.runCatching {
GSON.fromJsonObject<Map<String, String>>(readText()).getOrNull()
}?.getOrNull().orEmpty()
val entries = if (bookMap.isNotEmpty()) {
bookMap.entries.map { it.key to it.value }
} else {
localBooksDir.listFiles()
?.filter { it.isFile && it.name != Backup.localBooksMapFileName }
?.map { null to it.name }
.orEmpty()
}
if (entries.isEmpty()) return
var restored = 0
entries.forEach { (oldBookUrl, fileName) ->
val bookFile = File(localBooksDir, fileName)
if (!bookFile.isFile) return@forEach
kotlin.runCatching {
val restoredUri = FileInputStream(bookFile).use { input ->
// 使用备份目录中的唯一文件名,避免目标目录中存在同名旧书时串书。
LocalBook.saveBookFile(input, fileName)
}
val newBookUrl = FileDoc.fromUri(restoredUri, false).toString()
if (!oldBookUrl.isNullOrBlank() && oldBookUrl != newBookUrl) {
rebindRestoredBook(oldBookUrl, newBookUrl)
}
restored++
}.onFailure {
AppLog.put("恢复本地书籍 ${bookFile.name} 出错\n${it.localizedMessage}", it)
}
}
LogUtils.d(TAG, "恢复本地书籍 $restored")
}
private fun rebindRestoredBook(oldBookUrl: String, newBookUrl: String) {
val oldBook = appDb.bookDao.getBook(oldBookUrl) ?: return
val newBook = oldBook.copy(
bookUrl = newBookUrl,
coverUrl = LocalBook.getCoverPath(oldBook.copy(bookUrl = newBookUrl))
)
appDb.runInTransaction {
appDb.bookDao.replace(oldBook, newBook)
BookHelp.updateCacheFolder(oldBook, newBook)
appDb.readRecordDao.replaceBookUrl(oldBookUrl, newBookUrl)
}
}
private suspend fun applyConfigMap(map: Map<String, Any?>, aes: BackupAES) { private suspend fun applyConfigMap(map: Map<String, Any?>, aes: BackupAES) {
val finalMap = mutableMapOf<String, Any>() val finalMap = mutableMapOf<String, Any>()
appCtx.defaultSharedPreferences.edit { appCtx.defaultSharedPreferences.edit {
@@ -3,7 +3,6 @@ package io.legado.app.help.update
import android.os.Build import android.os.Build
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import io.legado.app.exception.NoStackTraceException
import java.time.Instant import java.time.Instant
data class AppReleaseInfo( data class AppReleaseInfo(
@@ -25,9 +24,11 @@ enum class AppVariant {
} }
@Keep @Keep
data class GithubRelease( data class GiteaRelease(
val assets: List<Asset>?, val assets: List<GiteaAsset>?,
val body: String, val body: String?,
@SerializedName("draft")
val isDraft: Boolean = false,
@SerializedName("prerelease") @SerializedName("prerelease")
val isPreRelease: Boolean, val isPreRelease: Boolean,
@SerializedName("tag_name") @SerializedName("tag_name")
@@ -36,8 +37,8 @@ data class GithubRelease(
@SerializedName("created_at") @SerializedName("created_at")
val createdAt: String? val createdAt: String?
) { ) {
fun gitReleaseToAppReleaseInfo(): List<AppReleaseInfo> { fun toAppReleaseInfo(): List<AppReleaseInfo> {
assets ?: throw NoStackTraceException("获取新版本出错") val releaseAssets = assets.orEmpty()
val version = tagName val version = tagName
val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "" val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: ""
@@ -47,36 +48,38 @@ data class GithubRelease(
else -> "" else -> ""
} }
return assets return releaseAssets
.filter { it.isValid } .filter { it.isValid }
.filter { asset -> .filter { asset ->
abiSuffix.isEmpty() || asset.name.contains(abiSuffix, ignoreCase = true) abiSuffix.isEmpty() || asset.name.contains(abiSuffix, ignoreCase = true)
} }
.map { it.assetToAppReleaseInfo(isPreRelease, body, version) } .map { it.assetToAppReleaseInfo(isPreRelease, body.orEmpty(), version) }
} }
} }
@Keep @Keep
data class Asset( data class GiteaAsset(
@SerializedName("browser_download_url") @SerializedName("browser_download_url")
val apkUrl: String, val apkUrl: String?,
@SerializedName("content_type") @SerializedName("content_type")
val contentType: String, val contentType: String?,
@SerializedName("created_at") @SerializedName("created_at")
val createdAt: String, val createdAt: String?,
@SerializedName("download_count") @SerializedName("download_count")
val downloadCount: Int, val downloadCount: Int,
val id: Int, val id: Int,
val name: String, val name: String,
val state: String?, val state: String?,
val url: String val url: String?
) { ) {
val isValid: Boolean val isValid: Boolean
get() = name.endsWith(".apk", ignoreCase = true) && get() = name.endsWith(".apk", ignoreCase = true) &&
!apkUrl.isNullOrBlank() &&
!createdAt.isNullOrBlank() &&
(state == null || state == "uploaded") (state == null || state == "uploaded")
fun assetToAppReleaseInfo(preRelease: Boolean, note: String, version: String): AppReleaseInfo { fun assetToAppReleaseInfo(preRelease: Boolean, note: String, version: String): AppReleaseInfo {
val instant = Instant.parse(createdAt) val instant = Instant.parse(createdAt!!)
val timestamp: Long = instant.toEpochMilli() val timestamp: Long = instant.toEpochMilli()
val appVariant = if (preRelease) AppVariant.BETA_RELEASE else AppVariant.OFFICIAL val appVariant = if (preRelease) AppVariant.BETA_RELEASE else AppVariant.OFFICIAL
@@ -85,8 +88,8 @@ data class Asset(
createdAt = timestamp, createdAt = timestamp,
note = note, note = note,
name = name, name = name,
downloadUrl = apkUrl, downloadUrl = apkUrl!!,
assetUrl = url, assetUrl = url.orEmpty(),
versionName = version versionName = version
) )
} }
@@ -5,8 +5,8 @@ import kotlinx.coroutines.CoroutineScope
object AppUpdate { object AppUpdate {
val gitHubUpdate: AppUpdateInterface? by lazy { val giteaUpdate: AppUpdateInterface by lazy {
AppUpdateGitHub AppUpdateGitea
} }
data class UpdateInfo( data class UpdateInfo(
@@ -1,5 +1,6 @@
package io.legado.app.help.update package io.legado.app.help.update
import io.legado.app.R
import io.legado.app.constant.AppConst import io.legado.app.constant.AppConst
import io.legado.app.exception.NoStackTraceException import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.config.AppConfig import io.legado.app.help.config.AppConfig
@@ -11,8 +12,9 @@ import io.legado.app.utils.GSON
import io.legado.app.utils.fromJsonArray import io.legado.app.utils.fromJsonArray
import io.legado.app.utils.fromJsonObject import io.legado.app.utils.fromJsonObject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import splitties.init.appCtx
object AppUpdateGitHub : AppUpdate.AppUpdateInterface { object AppUpdateGitea : AppUpdate.AppUpdateInterface {
private const val RELEASES_API = private const val RELEASES_API =
"https://gitea.yamby.cn/api/v1/repos/yusheng/QieKan-3.0/releases" "https://gitea.yamby.cn/api/v1/repos/yusheng/QieKan-3.0/releases"
@@ -22,49 +24,31 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
"official_version" -> AppVariant.OFFICIAL "official_version" -> AppVariant.OFFICIAL
"beta_release_version" -> AppVariant.BETA_RELEASE "beta_release_version" -> AppVariant.BETA_RELEASE
"all_version" -> AppVariant.ALL "all_version" -> AppVariant.ALL
else -> AppConst.appInfo.appVariant else -> AppConst.appInfo.appVariant.takeIf { it != AppVariant.UNKNOWN }
?: AppVariant.ALL
} }
private suspend fun getLatestRelease(): List<AppReleaseInfo> { private suspend fun getLatestRelease(): List<AppReleaseInfo> {
val url = if (checkVariant == AppVariant.OFFICIAL) // Gitea 当前没有兼容 GitHub 的 /releases/latest 路由,统一读取列表后筛选。
"$RELEASES_API/latest" val res = okHttpClient.newCallResponse { url(RELEASES_API) }
else if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(" + res.code + ")")
RELEASES_API
val res = okHttpClient.newCallResponse { url(url) }
if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})")
val body = res.body.text() val body = res.body.text()
if (body.isBlank()) throw NoStackTraceException("获取新版本出错") if (body.isBlank()) throw NoStackTraceException("获取新版本出错")
return when (checkVariant) { val releases = GSON.fromJsonArray<GiteaRelease>(body)
AppVariant.BETA_RELEASE -> { .getOrElse { throw NoStackTraceException("解析失败 " + it.localizedMessage) }
val releases = GSON.fromJsonArray<GithubRelease>(body) return releases
.getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") } .filterNot { it.isDraft }
.filter {
releases.filter { it.isPreRelease } when (checkVariant) {
.flatMap { it.gitReleaseToAppReleaseInfo() } AppVariant.OFFICIAL -> !it.isPreRelease
.sortedByDescending { it.createdAt } AppVariant.BETA_RELEASE -> it.isPreRelease
AppVariant.ALL, AppVariant.UNKNOWN -> true
}
} }
.flatMap { it.toAppReleaseInfo() }
AppVariant.OFFICIAL -> { .sortedByDescending { it.createdAt }
val release = GSON.fromJsonObject<GithubRelease>(body)
.getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
release.gitReleaseToAppReleaseInfo()
.sortedByDescending { it.createdAt }
}
AppVariant.ALL -> {
val releases = GSON.fromJsonArray<GithubRelease>(body)
.getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
releases.flatMap { it.gitReleaseToAppReleaseInfo() }
.sortedByDescending { it.createdAt }
}
else -> emptyList()
}
} }
suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? { suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
@@ -73,8 +57,8 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
if (!res.isSuccessful) return null if (!res.isSuccessful) return null
val body = res.body.text() val body = res.body.text()
val release = GSON.fromJsonObject<GithubRelease>(body).getOrElse { return null } val release = GSON.fromJsonObject<GiteaRelease>(body).getOrElse { return null }
val info = release.gitReleaseToAppReleaseInfo().firstOrNull() ?: return null val info = release.toAppReleaseInfo().firstOrNull() ?: return null
return AppUpdate.UpdateInfo( return AppUpdate.UpdateInfo(
tagName = info.versionName, tagName = info.versionName,
@@ -112,7 +96,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface {
) )
} }
throw NoStackTraceException("已是最新版本") throw NoStackTraceException(appCtx.getString(R.string.already_latest_version))
}.timeout(10000) }.timeout(10000)
} }
@@ -88,6 +88,7 @@ private fun MaterialAboutScreen(
val privacyPolicyTitle = stringResource(R.string.about_privacy_policy_title) val privacyPolicyTitle = stringResource(R.string.about_privacy_policy_title)
val licenseTitle = stringResource(R.string.about_license_title) val licenseTitle = stringResource(R.string.about_license_title)
val disclaimerTitle = stringResource(R.string.about_disclaimer_title) val disclaimerTitle = stringResource(R.string.about_disclaimer_title)
val giteaUrl = stringResource(R.string.gitea_url)
AppScaffold( AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -145,13 +146,13 @@ private fun MaterialAboutScreen(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center horizontalArrangement = Arrangement.Center
) { ) {
FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }) { FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl(giteaUrl)) }) {
Icon( Icon(
painter = painterResource(R.drawable.ic_web_outline), painter = painterResource(R.drawable.ic_web_outline),
contentDescription = stringResource(R.string.back) contentDescription = stringResource(R.string.back)
) )
} }
FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }) { FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl(giteaUrl)) }) {
Icon( Icon(
painter = painterResource(R.drawable.ic_github), painter = painterResource(R.drawable.ic_github),
contentDescription = stringResource(R.string.back) contentDescription = stringResource(R.string.back)
@@ -172,7 +173,7 @@ private fun MaterialAboutScreen(
SettingItemWithDivider { SettingItemWithDivider {
SettingItem( SettingItem(
title = stringResource(R.string.contributors), title = stringResource(R.string.contributors),
onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) } onClick = { onIntent(AboutIntent.OpenUrl(giteaUrl)) }
) )
} }
SettingItemWithDivider { SettingItemWithDivider {
@@ -58,7 +58,7 @@ class AboutViewModel(application: Application) : BaseViewModel(application) {
private fun checkUpdate() { private fun checkUpdate() {
_uiState.update { it.copy(dialog = AboutDialog.CheckingUpdate) } _uiState.update { it.copy(dialog = AboutDialog.CheckingUpdate) }
AppUpdate.gitHubUpdate?.run { AppUpdate.giteaUpdate.run {
check(viewModelScope) check(viewModelScope)
.onSuccess { updateInfo -> .onSuccess { updateInfo ->
_uiState.update { _uiState.update {
@@ -69,8 +69,15 @@ class AboutViewModel(application: Application) : BaseViewModel(application) {
} }
}.onError { e -> }.onError { e ->
_uiState.update { it.copy(dialog = null) } _uiState.update { it.copy(dialog = null) }
val message = e.localizedMessage.orEmpty()
if (message == context.getString(R.string.already_latest_version)) {
_effects.tryEmit(
AboutEffect.ShowToast(context.getString(R.string.already_latest_version))
)
return@onError
}
_effects.tryEmit( _effects.tryEmit(
AboutEffect.ShowToast("${context.getString(R.string.check_update)}\n${e.localizedMessage}") AboutEffect.ShowToast(context.getString(R.string.check_update) + "\n" + message)
) )
}.onFinally { }.onFinally {
_uiState.update { it.copy(dialog = null) } _uiState.update { it.copy(dialog = null) }
@@ -184,6 +184,7 @@ private fun AboutContent(
val privacyPolicyTitle = stringResource(R.string.about_privacy_policy_title) val privacyPolicyTitle = stringResource(R.string.about_privacy_policy_title)
val licenseTitle = stringResource(R.string.about_license_title) val licenseTitle = stringResource(R.string.about_license_title)
val disclaimerTitle = stringResource(R.string.about_disclaimer_title) val disclaimerTitle = stringResource(R.string.about_disclaimer_title)
val giteaUrl = stringResource(R.string.gitea_url)
val backdrop = rememberBlurBackdrop() val backdrop = rememberBlurBackdrop()
var blurRadius by remember { mutableFloatStateOf(60f) } var blurRadius by remember { mutableFloatStateOf(60f) }
@@ -393,9 +394,9 @@ private fun AboutContent(
ArrowPreference( ArrowPreference(
title = stringResource(R.string.contributors), title = stringResource(R.string.contributors),
endActions = { endActions = {
ValueText("GitHub") ValueText(stringResource(R.string.gitea))
}, },
onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }, onClick = { onIntent(AboutIntent.OpenUrl(giteaUrl)) },
) )
} }
@@ -0,0 +1,51 @@
package io.legado.app.ui.audiobook
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class AudiobookUiState(
val isLoading: Boolean = false,
val isConnected: Boolean = false,
val profileId: Long? = null,
val serverName: String = "Audiobookshelf",
val baseUrl: String = "",
val username: String = "",
val password: String = "",
val serverVersion: String = "",
val libraries: ImmutableList<AudiobookLibraryItemUi> = persistentListOf(),
val dialog: AudiobookDialog? = null
) {
val canConnect: Boolean
get() = baseUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
}
@Stable
data class AudiobookLibraryItemUi(
val id: String,
val name: String,
val mediaType: String,
val icon: String?
)
sealed interface AudiobookIntent {
data class ServerNameChanged(val value: String) : AudiobookIntent
data class BaseUrlChanged(val value: String) : AudiobookIntent
data class UsernameChanged(val value: String) : AudiobookIntent
data class PasswordChanged(val value: String) : AudiobookIntent
data object Connect : AudiobookIntent
data object Refresh : AudiobookIntent
data object EditConnection : AudiobookIntent
data object RequestForget : AudiobookIntent
data object ConfirmForget : AudiobookIntent
data object DismissDialog : AudiobookIntent
}
sealed interface AudiobookEffect {
data class ShowMessage(val message: String) : AudiobookEffect
}
sealed interface AudiobookDialog {
data object ConfirmForget : AudiobookDialog
}
@@ -0,0 +1,326 @@
package io.legado.app.ui.audiobook
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Headphones
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.SettingItemWithDivider
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.PrimaryButton
import io.legado.app.ui.widget.components.button.SecondaryButton
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.SettingItem
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import org.koin.androidx.compose.koinViewModel
@Composable
fun AudiobookRouteScreen(
onBackClick: () -> Unit,
viewModel: AudiobookViewModel = koinViewModel()
) {
AudiobookScreen(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
effects = viewModel.effects,
onIntent = viewModel::onIntent,
onBackClick = onBackClick
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AudiobookScreen(
state: AudiobookUiState,
effects: Flow<AudiobookEffect>,
onIntent: (AudiobookIntent) -> Unit,
onBackClick: () -> Unit
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
effects.collectLatest { effect ->
when (effect) {
is AudiobookEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
}
}
}
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
GlassMediumFlexibleTopAppBar(
title = stringResource(R.string.audiobookshelf_title),
navigationIcon = { TopBarNavigationButton(onClick = onBackClick) },
actions = {
if (state.isConnected) {
TopBarActionButton(
onClick = { onIntent(AudiobookIntent.Refresh) },
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.audiobookshelf_refresh)
)
}
},
scrollBehavior = scrollBehavior
)
}
) { paddingValues ->
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(),
bottom = 48.dp
)
) {
item {
if (state.isConnected) {
ConnectedContent(state = state, onIntent = onIntent)
} else {
ConnectionForm(state = state, onIntent = onIntent)
}
}
}
if (state.isLoading) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
)
}
}
}
AppAlertDialog(
show = state.dialog == AudiobookDialog.ConfirmForget,
onDismissRequest = { onIntent(AudiobookIntent.DismissDialog) },
title = stringResource(R.string.audiobookshelf_forget_title),
text = stringResource(R.string.audiobookshelf_forget_message),
confirmText = stringResource(R.string.audiobookshelf_forget_confirm),
onConfirm = { onIntent(AudiobookIntent.ConfirmForget) },
dismissText = stringResource(R.string.audiobookshelf_cancel),
onDismiss = { onIntent(AudiobookIntent.DismissDialog) }
)
}
@Composable
private fun ConnectionForm(
state: AudiobookUiState,
onIntent: (AudiobookIntent) -> Unit
) {
var passwordVisible by rememberSaveable { mutableStateOf(false) }
SplicedColumnGroup(title = stringResource(R.string.audiobookshelf_server_section)) {
Column(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainerLow)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
AppTextField(
value = state.serverName,
onValueChange = { onIntent(AudiobookIntent.ServerNameChanged(it)) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
label = stringResource(R.string.audiobookshelf_server_name),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
AppTextField(
value = state.baseUrl,
onValueChange = { onIntent(AudiobookIntent.BaseUrlChanged(it)) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
label = stringResource(R.string.audiobookshelf_server_url),
placeholder = { AppText(stringResource(R.string.audiobookshelf_server_url_example)) },
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
)
)
if (state.baseUrl.trim().startsWith("http://", ignoreCase = true)) {
AppText(
text = stringResource(R.string.audiobookshelf_http_warning),
style = LegadoTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.tertiary
)
}
AppTextField(
value = state.username,
onValueChange = { onIntent(AudiobookIntent.UsernameChanged(it)) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
label = stringResource(R.string.audiobookshelf_username),
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
)
AppTextField(
value = state.password,
onValueChange = { onIntent(AudiobookIntent.PasswordChanged(it)) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
label = stringResource(R.string.audiobookshelf_password),
singleLine = true,
visualTransformation = if (passwordVisible) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) {
Icons.Default.VisibilityOff
} else {
Icons.Default.Visibility
},
contentDescription = null
)
}
},
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done
)
)
PrimaryButton(
onClick = { onIntent(AudiobookIntent.Connect) },
modifier = Modifier.fillMaxWidth(),
enabled = state.canConnect && !state.isLoading,
text = stringResource(
if (state.profileId == null) {
R.string.audiobookshelf_connect
} else {
R.string.audiobookshelf_reconnect
}
)
)
if (state.profileId != null) {
SecondaryButton(
onClick = { onIntent(AudiobookIntent.RequestForget) },
modifier = Modifier.fillMaxWidth(),
enabled = !state.isLoading,
text = stringResource(R.string.audiobookshelf_forget_confirm)
)
}
}
}
}
@Composable
private fun ConnectedContent(
state: AudiobookUiState,
onIntent: (AudiobookIntent) -> Unit
) {
Column(modifier = Modifier.fillMaxWidth()) {
SplicedColumnGroup(title = stringResource(R.string.audiobookshelf_server_section)) {
SettingItemWithDivider {
SettingItem(
title = state.serverName,
description = state.baseUrl,
option = stringResource(
R.string.audiobookshelf_server_version,
state.serverVersion.ifBlank { "-" }
),
imageVector = Icons.Default.Headphones
)
}
}
SplicedColumnGroup(title = stringResource(R.string.audiobookshelf_actions_section)) {
ClickableSettingItem(
title = stringResource(R.string.audiobookshelf_refresh),
imageVector = Icons.Default.Refresh,
onClick = { onIntent(AudiobookIntent.Refresh) }
)
ClickableSettingItem(
title = stringResource(R.string.audiobookshelf_edit_connection),
imageVector = Icons.Default.Edit,
onClick = { onIntent(AudiobookIntent.EditConnection) }
)
ClickableSettingItem(
title = stringResource(R.string.audiobookshelf_forget_confirm),
imageVector = Icons.Default.DeleteOutline,
onClick = { onIntent(AudiobookIntent.RequestForget) }
)
}
SplicedColumnGroup(title = stringResource(R.string.audiobookshelf_library_section)) {
if (state.libraries.isEmpty()) {
SettingItemWithDivider {
SettingItem(
title = stringResource(R.string.audiobookshelf_no_libraries),
description = stringResource(R.string.audiobookshelf_no_libraries_desc),
imageVector = Icons.Default.LibraryMusic
)
}
} else {
state.libraries.forEach { library ->
SettingItemWithDivider {
SettingItem(
title = library.name,
description = when (library.mediaType) {
"book" -> stringResource(R.string.audiobookshelf_library_books)
"podcast" -> stringResource(R.string.audiobookshelf_library_podcasts)
else -> library.mediaType
},
imageVector = Icons.Default.LibraryMusic
)
}
}
}
}
}
}
@@ -0,0 +1,160 @@
package io.legado.app.ui.audiobook
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.domain.gateway.AudiobookGateway
import io.legado.app.domain.model.AudiobookHomeSnapshot
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AudiobookViewModel(
private val audiobookGateway: AudiobookGateway
) : ViewModel() {
private val _uiState = MutableStateFlow(AudiobookUiState())
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<AudiobookEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
init {
loadSavedProfile()
}
fun onIntent(intent: AudiobookIntent) {
when (intent) {
is AudiobookIntent.ServerNameChanged -> updateForm { copy(serverName = intent.value) }
is AudiobookIntent.BaseUrlChanged -> updateForm { copy(baseUrl = intent.value) }
is AudiobookIntent.UsernameChanged -> updateForm { copy(username = intent.value) }
is AudiobookIntent.PasswordChanged -> updateForm { copy(password = intent.value) }
AudiobookIntent.Connect -> connect()
AudiobookIntent.Refresh -> refresh()
AudiobookIntent.EditConnection -> {
_uiState.update { it.copy(isConnected = false, password = "") }
}
AudiobookIntent.RequestForget -> {
_uiState.update { it.copy(dialog = AudiobookDialog.ConfirmForget) }
}
AudiobookIntent.ConfirmForget -> forget()
AudiobookIntent.DismissDialog -> {
_uiState.update { it.copy(dialog = null) }
}
}
}
private fun loadSavedProfile() {
viewModelScope.launch {
runCatching { audiobookGateway.getSavedProfile() }
.onFailure(::showError)
.getOrNull()
?.let { profile ->
_uiState.update {
it.copy(
isLoading = true,
profileId = profile.id,
serverName = profile.name,
baseUrl = profile.baseUrl,
username = profile.username,
password = ""
)
}
runCatching { audiobookGateway.restore(profile.id) }
.onSuccess(::showSnapshot)
.onFailure { error ->
_uiState.update { it.copy(isLoading = false, isConnected = false) }
showError(error)
}
}
}
}
private fun connect() {
val state = _uiState.value
if (!state.canConnect || state.isLoading) return
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch {
runCatching {
audiobookGateway.connect(
existingProfileId = state.profileId,
name = state.serverName,
baseUrl = state.baseUrl,
username = state.username,
password = state.password
)
}.onSuccess(::showSnapshot)
.onFailure { error ->
_uiState.update { it.copy(isLoading = false, isConnected = false) }
showError(error)
}
}
}
private fun refresh() {
val profileId = _uiState.value.profileId ?: return
if (_uiState.value.isLoading) return
_uiState.update { it.copy(isLoading = true) }
viewModelScope.launch {
runCatching { audiobookGateway.refresh(profileId) }
.onSuccess(::showSnapshot)
.onFailure { error ->
_uiState.update { it.copy(isLoading = false) }
showError(error)
}
}
}
private fun forget() {
val profileId = _uiState.value.profileId ?: return
_uiState.update { it.copy(isLoading = true, dialog = null) }
viewModelScope.launch {
runCatching { audiobookGateway.forget(profileId) }
.onSuccess { _uiState.value = AudiobookUiState() }
.onFailure { error ->
_uiState.update { it.copy(isLoading = false) }
showError(error)
}
}
}
private fun showSnapshot(snapshot: AudiobookHomeSnapshot) {
_uiState.update {
it.copy(
isLoading = false,
isConnected = true,
profileId = snapshot.profile.id,
serverName = snapshot.profile.name,
baseUrl = snapshot.profile.baseUrl,
username = snapshot.profile.username,
password = "",
serverVersion = snapshot.serverVersion,
libraries = snapshot.libraries.map { library ->
AudiobookLibraryItemUi(
id = library.id,
name = library.name,
mediaType = library.mediaType,
icon = library.icon
)
}.toImmutableList(),
dialog = null
)
}
}
private fun showError(error: Throwable) {
_effects.tryEmit(
AudiobookEffect.ShowMessage(
error.message?.takeIf { it.isNotBlank() } ?: "有声书服务连接失败"
)
)
}
private fun updateForm(transform: AudiobookUiState.() -> AudiobookUiState) {
if (_uiState.value.isLoading) return
_uiState.update(transform)
}
}
@@ -55,6 +55,16 @@ object BackupConfig {
"both" "both"
) )
var backupFonts by prefDelegate(
PreferKey.backupFonts,
true
)
var backupLocalBooks by prefDelegate(
PreferKey.backupLocalBooks,
false
)
var backupPath by prefDelegate<String?>( var backupPath by prefDelegate<String?>(
PreferKey.backupPath, PreferKey.backupPath,
null null
@@ -347,6 +347,20 @@ fun BackupConfigScreen(
checked = BackupConfig.onlyLatestBackup, checked = BackupConfig.onlyLatestBackup,
onCheckedChange = { BackupConfig.onlyLatestBackup = it } onCheckedChange = { BackupConfig.onlyLatestBackup = it }
) )
SwitchSettingItem(
title = stringResource(R.string.backup_fonts_t),
description = stringResource(R.string.backup_fonts_s),
checked = BackupConfig.backupFonts,
onCheckedChange = { BackupConfig.backupFonts = it }
)
SwitchSettingItem(
title = stringResource(R.string.backup_local_books_t),
description = stringResource(R.string.backup_local_books_s),
checked = BackupConfig.backupLocalBooks,
onCheckedChange = { BackupConfig.backupLocalBooks = it }
)
} }
} }
} }
@@ -40,7 +40,7 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig import io.legado.app.help.config.LocalConfig
import io.legado.app.help.coroutine.Coroutine import io.legado.app.help.coroutine.Coroutine
import io.legado.app.help.storage.Backup import io.legado.app.help.storage.Backup
import io.legado.app.help.update.AppUpdateGitHub import io.legado.app.help.update.AppUpdateGitea
import io.legado.app.lib.dialogs.alert import io.legado.app.lib.dialogs.alert
import io.legado.app.service.WebService import io.legado.app.service.WebService
import io.legado.app.ui.about.CrashLogsDialog import io.legado.app.ui.about.CrashLogsDialog
@@ -346,7 +346,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback {
if (!BuildConfig.DEBUG) { if (!BuildConfig.DEBUG) {
lifecycleScope.launch { lifecycleScope.launch {
try { try {
val info = AppUpdateGitHub.getReleaseByTag(BuildConfig.VERSION_NAME) val info = AppUpdateGitea.getReleaseByTag(BuildConfig.VERSION_NAME)
if (info != null) { if (info != null) {
val dialog = UpdateDialog(info, UpdateDialog.Mode.VIEW_LOG) val dialog = UpdateDialog(info, UpdateDialog.Mode.VIEW_LOG)
dialog.setOnDismissListener { block.resume(null) } dialog.setOnDismissListener { block.resume(null) }
@@ -26,6 +26,7 @@ import io.legado.app.ui.about.AboutEffect
import io.legado.app.ui.about.AboutScreen import io.legado.app.ui.about.AboutScreen
import io.legado.app.ui.about.AboutViewModel import io.legado.app.ui.about.AboutViewModel
import io.legado.app.ui.ai.chat.AiChatRouteScreen import io.legado.app.ui.ai.chat.AiChatRouteScreen
import io.legado.app.ui.audiobook.AudiobookRouteScreen
import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
import io.legado.app.ui.book.explore.ExploreShowIntent import io.legado.app.ui.book.explore.ExploreShowIntent
import io.legado.app.ui.book.explore.ExploreShowScreen import io.legado.app.ui.book.explore.ExploreShowScreen
@@ -99,6 +100,9 @@ fun MainActivity.mainEntryProvider(
onNavigateToChat = { onNavigateToChat = {
onNavigateToRoute(MainRouteAiChat) onNavigateToRoute(MainRouteAiChat)
}, },
onNavigateToAudiobooks = {
onNavigateToRoute(MainRouteAudiobooks)
},
onNavigateToSearch = { key -> onNavigateToSearch = { key ->
onNavigateToRoute( onNavigateToRoute(
MainRouteSearch( MainRouteSearch(
@@ -724,4 +728,8 @@ fun MainActivity.mainEntryProvider(
onBack = { onNavigateBack() }, onBack = { onNavigateBack() },
) )
} }
entry<MainRouteAudiobooks> {
AudiobookRouteScreen(onBackClick = { onNavigateBack() })
}
} }
@@ -36,6 +36,9 @@ data object MainRouteSettingsAi : MainRoute
@Serializable @Serializable
data object MainRouteAiChat : MainRoute data object MainRouteAiChat : MainRoute
@Serializable
data object MainRouteAudiobooks : MainRoute
@Serializable @Serializable
data class MainRouteSettingsAiProviderEdit( data class MainRouteSettingsAiProviderEdit(
val providerId: String? = null val providerId: String? = null
@@ -71,6 +71,7 @@ object MainNavigator {
MainRouteImportLocal, MainRouteImportLocal,
MainRouteImportRemote, MainRouteImportRemote,
MainRouteAudiobooks,
is MainRouteCache, is MainRouteCache,
MainRouteBookCacheManage, MainRouteBookCacheManage,
is MainRouteReadBook -> { is MainRouteReadBook -> {
@@ -118,6 +118,7 @@ fun MainScreen(
useRail: Boolean, useRail: Boolean,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onNavigateToChat: () -> Unit, onNavigateToChat: () -> Unit,
onNavigateToAudiobooks: () -> Unit,
onNavigateToSearch: (String?) -> Unit, onNavigateToSearch: (String?) -> Unit,
onNavigateToRemoteImport: () -> Unit, onNavigateToRemoteImport: () -> Unit,
onNavigateToLocalImport: () -> Unit, onNavigateToLocalImport: () -> Unit,
@@ -478,6 +479,7 @@ fun MainScreen(
MainDestination.My -> MyScreen( MainDestination.My -> MyScreen(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onNavigateToChat = onNavigateToChat, onNavigateToChat = onNavigateToChat,
onNavigateToAudiobooks = onNavigateToAudiobooks,
onNavigate = { event -> onNavigate = { event ->
when (event) { when (event) {
PrefClickEvent.OpenBookCacheManage -> onNavigateToBookCacheManage() PrefClickEvent.OpenBookCacheManage -> onNavigateToBookCacheManage()
@@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.FindReplace import androidx.compose.material.icons.filled.FindReplace
import androidx.compose.material.icons.filled.Folder import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Headphones
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.OpenInBrowser import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.filled.Sell import androidx.compose.material.icons.filled.Sell
@@ -69,6 +70,7 @@ fun MyScreen(
viewModel: MyViewModel = koinViewModel(), viewModel: MyViewModel = koinViewModel(),
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onNavigateToChat: () -> Unit, onNavigateToChat: () -> Unit,
onNavigateToAudiobooks: () -> Unit,
onNavigate: (PrefClickEvent) -> Unit onNavigate: (PrefClickEvent) -> Unit
) { ) {
@@ -172,6 +174,12 @@ fun MyScreen(
SplicedColumnGroup( SplicedColumnGroup(
title = stringResource(R.string.other) title = stringResource(R.string.other)
) { ) {
ClickableSettingItem(
title = stringResource(R.string.audiobookshelf_title),
description = stringResource(R.string.audiobookshelf_entry_description),
imageVector = Icons.Default.Headphones,
onClick = onNavigateToAudiobooks
)
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.ai_chat), title = stringResource(R.string.ai_chat),
imageVector = Icons.Default.AutoAwesome, imageVector = Icons.Default.AutoAwesome,
+30 -1
View File
@@ -235,6 +235,7 @@
<string name="paste_source">粘贴源</string> <string name="paste_source">粘贴源</string>
<string name="source_rule_s">源规则说明</string> <string name="source_rule_s">源规则说明</string>
<string name="check_update">检查更新</string> <string name="check_update">检查更新</string>
<string name="already_latest_version">当前已是最新版本</string>
<string name="camera_scan">扫描二维码</string> <string name="camera_scan">扫描二维码</string>
<string name="scan_image">扫描本地图片</string> <string name="scan_image">扫描本地图片</string>
<string name="rule_summary">规则说明</string> <string name="rule_summary">规则说明</string>
@@ -876,7 +877,7 @@
<string name="alipay_red_envelope_qr_code">支付宝红包二维码</string> <string name="alipay_red_envelope_qr_code">支付宝红包二维码</string>
<string name="alipay_payment_qr_code">支付宝收款二维码</string> <string name="alipay_payment_qr_code">支付宝收款二维码</string>
<string name="qq_collection_qr_code">QQ 收款二维码</string> <string name="qq_collection_qr_code">QQ 收款二维码</string>
<string name="contributors_summary">gedoor、Invinciblelee 和 Xwite 等,详情请在 GitHub 中查看</string> <string name="contributors_summary">gedoor、Invinciblelee 和 Xwite 等,详情请在 Gitea 项目中查看</string>
<string name="clear_cache_summary">清除已下载书籍和字体缓存</string> <string name="clear_cache_summary">清除已下载书籍和字体缓存</string>
<string name="default_cover">默认封面</string> <string name="default_cover">默认封面</string>
<string name="restore_ignore">恢复忽略列表</string> <string name="restore_ignore">恢复忽略列表</string>
@@ -1306,6 +1307,10 @@
<string name="set_local_password_summary">本地密码用来对备份的敏感信息加密和解密,如需在不同设备之间同步,本地密码需一致。</string> <string name="set_local_password_summary">本地密码用来对备份的敏感信息加密和解密,如需在不同设备之间同步,本地密码需一致。</string>
<string name="only_latest_backup_t">仅保留最新备份</string> <string name="only_latest_backup_t">仅保留最新备份</string>
<string name="only_latest_backup_s">本地备份仅保留最新备份文件</string> <string name="only_latest_backup_s">本地备份仅保留最新备份文件</string>
<string name="backup_fonts_t">备份自定义字体</string>
<string name="backup_fonts_s">将阅读设置中使用的自定义字体一并备份,恢复时自动还原</string>
<string name="backup_local_books_t">备份本地书籍</string>
<string name="backup_local_books_s">将本地书籍文件一并备份,恢复时自动导入到书籍保存目录</string>
<string name="webdav_application_authorization_error">WebDav 应用验证失败</string> <string name="webdav_application_authorization_error">WebDav 应用验证失败</string>
<string name="load_word_count">加载字数</string> <string name="load_word_count">加载字数</string>
<string name="replace_exclude_scope">排除范围,选填书名或者书源 URL</string> <string name="replace_exclude_scope">排除范围,选填书名或者书源 URL</string>
@@ -2245,4 +2250,28 @@
<string name="cover_album_imported_name">%1$s(导入)</string> <string name="cover_album_imported_name">%1$s(导入)</string>
<string name="cover_album_day_night_count">日间 %1$d · 夜间 %2$d</string> <string name="cover_album_day_night_count">日间 %1$d · 夜间 %2$d</string>
<string name="cover_album_selected">当前使用</string> <string name="cover_album_selected">当前使用</string>
<string name="audiobookshelf_title">有声书</string>
<string name="audiobookshelf_entry_description">连接 Audiobookshelf 服务</string>
<string name="audiobookshelf_server_section">服务器</string>
<string name="audiobookshelf_library_section">媒体库</string>
<string name="audiobookshelf_actions_section">连接管理</string>
<string name="audiobookshelf_server_name">服务器名称</string>
<string name="audiobookshelf_server_url">服务器地址</string>
<string name="audiobookshelf_server_url_example">https://example.com/audiobookshelf</string>
<string name="audiobookshelf_username">用户名</string>
<string name="audiobookshelf_password">密码</string>
<string name="audiobookshelf_connect">连接并登录</string>
<string name="audiobookshelf_reconnect">重新登录</string>
<string name="audiobookshelf_refresh">刷新</string>
<string name="audiobookshelf_edit_connection">修改连接</string>
<string name="audiobookshelf_server_version">服务端版本:%1$s</string>
<string name="audiobookshelf_http_warning">HTTP 连接不会加密账号令牌,请仅在可信局域网中使用。</string>
<string name="audiobookshelf_no_libraries">没有可访问的媒体库</string>
<string name="audiobookshelf_no_libraries_desc">请检查该账号在服务端的媒体库权限。</string>
<string name="audiobookshelf_library_books">有声书媒体库</string>
<string name="audiobookshelf_library_podcasts">播客媒体库</string>
<string name="audiobookshelf_forget_title">移除这个服务器?</string>
<string name="audiobookshelf_forget_message">本地连接配置和加密刷新令牌将被删除,服务端数据不会受到影响。</string>
<string name="audiobookshelf_forget_confirm">移除服务器</string>
<string name="audiobookshelf_cancel">取消</string>
</resources> </resources>
+2 -1
View File
@@ -186,6 +186,7 @@
<string name="paste_source">粘帖源</string> <string name="paste_source">粘帖源</string>
<string name="source_rule_s">源規則説明</string> <string name="source_rule_s">源規則説明</string>
<string name="check_update">檢查更新</string> <string name="check_update">檢查更新</string>
<string name="already_latest_version">目前已是最新版本</string>
<string name="camera_scan">掃描 QR Code</string> <string name="camera_scan">掃描 QR Code</string>
<string name="scan_image">掃描本地圖片</string> <string name="scan_image">掃描本地圖片</string>
<string name="rule_summary">規則説明</string> <string name="rule_summary">規則説明</string>
@@ -772,7 +773,7 @@
<string name="alipay_red_envelope_qr_code">支付寶紅包二維碼</string> <string name="alipay_red_envelope_qr_code">支付寶紅包二維碼</string>
<string name="alipay_payment_qr_code">支付寶收款二維碼</string> <string name="alipay_payment_qr_code">支付寶收款二維碼</string>
<string name="qq_collection_qr_code">QQ收款二維碼</string> <string name="qq_collection_qr_code">QQ收款二維碼</string>
<string name="contributors_summary">gedoor,Invinciblelee,Xwite等,詳情請在github中查看</string> <string name="contributors_summary">gedoor,Invinciblelee,Xwite等,詳情請在 Gitea 專案中查看</string>
<string name="clear_cache_summary">清除已下載書籍和字體緩存</string> <string name="clear_cache_summary">清除已下載書籍和字體緩存</string>
<string name="default_cover">默認封面</string> <string name="default_cover">默認封面</string>
<string name="restore_ignore">恢復忽略列表</string> <string name="restore_ignore">恢復忽略列表</string>
+2 -1
View File
@@ -188,6 +188,7 @@
<string name="paste_source">貼上源</string> <string name="paste_source">貼上源</string>
<string name="source_rule_s">源規則說明</string> <string name="source_rule_s">源規則說明</string>
<string name="check_update">檢查更新</string> <string name="check_update">檢查更新</string>
<string name="already_latest_version">目前已是最新版本</string>
<string name="camera_scan">掃描二維碼</string> <string name="camera_scan">掃描二維碼</string>
<string name="scan_image">掃描本機圖片</string> <string name="scan_image">掃描本機圖片</string>
<string name="rule_summary">規則說明</string> <string name="rule_summary">規則說明</string>
@@ -780,7 +781,7 @@
<string name="alipay_red_envelope_qr_code">支付寶紅包二維碼</string> <string name="alipay_red_envelope_qr_code">支付寶紅包二維碼</string>
<string name="alipay_payment_qr_code">支付寶收款二維碼</string> <string name="alipay_payment_qr_code">支付寶收款二維碼</string>
<string name="qq_collection_qr_code">QQ收款二維碼</string> <string name="qq_collection_qr_code">QQ收款二維碼</string>
<string name="contributors_summary">gedoor、Invinciblelee、Xwite等,詳情請在github中查看</string> <string name="contributors_summary">gedoor、Invinciblelee、Xwite等,詳情請在 Gitea 專案中查看</string>
<string name="clear_cache_summary">清除已下載書籍和字體快取</string> <string name="clear_cache_summary">清除已下載書籍和字體快取</string>
<string name="default_cover">預設封面</string> <string name="default_cover">預設封面</string>
<string name="restore_ignore">復原忽略列表</string> <string name="restore_ignore">復原忽略列表</string>
+3 -3
View File
@@ -4,11 +4,11 @@
<string name="legado_gzh" translatable="false">开源阅读</string> <string name="legado_gzh" translatable="false">开源阅读</string>
<string name="email" translatable="false">gekunfei@live.com</string> <string name="email" translatable="false">gekunfei@live.com</string>
<string name="contributors_url" translatable="false">https://github.com/gedoor/legado/graphs/contributors</string> <string name="contributors_url" translatable="false">https://gitea.yamby.cn/yusheng/QieKan-3.0</string>
<string name="github_url" translatable="false">https://gitea.yamby.cn/yusheng/QieKan-3.0</string> <string name="gitea_url" translatable="false">https://gitea.yamby.cn/yusheng/QieKan-3.0</string>
<string name="legado_url" translatable="false">https://gedoor.github.io/</string> <string name="legado_url" translatable="false">https://gedoor.github.io/</string>
<string name="http_ip" translatable="false">http://%1$s:%2$d</string> <string name="http_ip" translatable="false">http://%1$s:%2$d</string>
<string name="git_hub" translatable="false">Gitea</string> <string name="gitea" translatable="false">Gitea</string>
<string name="diy_edit_source_group_title" translatable="false">【%s】</string> <string name="diy_edit_source_group_title" translatable="false">【%s】</string>
<string name="vip_title" translatable="false">🔒%s</string> <string name="vip_title" translatable="false">🔒%s</string>
<string name="payed_title" translatable="false">🔓%s</string> <string name="payed_title" translatable="false">🔓%s</string>
+30 -1
View File
@@ -256,6 +256,7 @@
<string name="paste_source">Paste source</string> <string name="paste_source">Paste source</string>
<string name="source_rule_s">Source rules description</string> <string name="source_rule_s">Source rules description</string>
<string name="check_update">Check for Updates</string> <string name="check_update">Check for Updates</string>
<string name="already_latest_version">You are already using the latest version</string>
<string name="camera_scan">Scan QR code</string> <string name="camera_scan">Scan QR code</string>
<string name="scan_image">Scan local images</string> <string name="scan_image">Scan local images</string>
<string name="rule_summary">Rules description</string> <string name="rule_summary">Rules description</string>
@@ -901,7 +902,7 @@
<string name="alipay_red_envelope_qr_code">AliPay red envelope QR code</string> <string name="alipay_red_envelope_qr_code">AliPay red envelope QR code</string>
<string name="alipay_payment_qr_code">AliPay QR code</string> <string name="alipay_payment_qr_code">AliPay QR code</string>
<string name="qq_collection_qr_code">QQ Collection QR code</string> <string name="qq_collection_qr_code">QQ Collection QR code</string>
<string name="contributors_summary">gedoor,Invinciblelee,Xwite etc. Checking in github for details</string> <string name="contributors_summary">gedoor,Invinciblelee,Xwite etc. See the Gitea project for details</string>
<string name="clear_cache_summary">Clear the cache of the downloaded books and fonts</string> <string name="clear_cache_summary">Clear the cache of the downloaded books and fonts</string>
<string name="default_cover">Default Cover</string> <string name="default_cover">Default Cover</string>
<string name="restore_ignore">Bypass List</string> <string name="restore_ignore">Bypass List</string>
@@ -1347,6 +1348,10 @@
<string name="set_local_password_summary">The local password is used to encrypt and decrypt the sensitive information of the backup, if you need to synchronize between different devices, the local password should be the same.</string> <string name="set_local_password_summary">The local password is used to encrypt and decrypt the sensitive information of the backup, if you need to synchronize between different devices, the local password should be the same.</string>
<string name="only_latest_backup_t">Keep Only the Latest Backup</string> <string name="only_latest_backup_t">Keep Only the Latest Backup</string>
<string name="only_latest_backup_s">Local backup keeps only the latest backup file</string> <string name="only_latest_backup_s">Local backup keeps only the latest backup file</string>
<string name="backup_fonts_t">Backup Custom Fonts</string>
<string name="backup_fonts_s">Include custom fonts used in reading settings in the backup, and restore them on the target device</string>
<string name="backup_local_books_t">Backup Local Books</string>
<string name="backup_local_books_s">Include the files of local books in the backup, and restore them to the book save folder on the target device</string>
<string name="webdav_application_authorization_error">Fail to authorize WebDAV application</string> <string name="webdav_application_authorization_error">Fail to authorize WebDAV application</string>
<string name="respondTime">respondTime: %1$d ms</string> <string name="respondTime">respondTime: %1$d ms</string>
<string name="load_word_count">Load word count</string> <string name="load_word_count">Load word count</string>
@@ -2250,4 +2255,28 @@
<string name="cover_album_imported_name">%1$s (Imported)</string> <string name="cover_album_imported_name">%1$s (Imported)</string>
<string name="cover_album_day_night_count">Light %1$d · Dark %2$d</string> <string name="cover_album_day_night_count">Light %1$d · Dark %2$d</string>
<string name="cover_album_selected">Currently selected</string> <string name="cover_album_selected">Currently selected</string>
<string name="audiobookshelf_title">Audiobooks</string>
<string name="audiobookshelf_entry_description">Connect to an Audiobookshelf server</string>
<string name="audiobookshelf_server_section">Server</string>
<string name="audiobookshelf_library_section">Libraries</string>
<string name="audiobookshelf_actions_section">Connection</string>
<string name="audiobookshelf_server_name">Server name</string>
<string name="audiobookshelf_server_url">Server address</string>
<string name="audiobookshelf_server_url_example">https://example.com/audiobookshelf</string>
<string name="audiobookshelf_username">Username</string>
<string name="audiobookshelf_password">Password</string>
<string name="audiobookshelf_connect">Connect and sign in</string>
<string name="audiobookshelf_reconnect">Sign in again</string>
<string name="audiobookshelf_refresh">Refresh</string>
<string name="audiobookshelf_edit_connection">Edit connection</string>
<string name="audiobookshelf_server_version">Server version: %1$s</string>
<string name="audiobookshelf_http_warning">HTTP sends account tokens without transport encryption. Use it only on a trusted LAN.</string>
<string name="audiobookshelf_no_libraries">No accessible libraries</string>
<string name="audiobookshelf_no_libraries_desc">Check this account\'s library permissions on the server.</string>
<string name="audiobookshelf_library_books">Audiobook library</string>
<string name="audiobookshelf_library_podcasts">Podcast library</string>
<string name="audiobookshelf_forget_title">Remove this server?</string>
<string name="audiobookshelf_forget_message">The local connection and encrypted refresh token will be removed. Server data will not be changed.</string>
<string name="audiobookshelf_forget_confirm">Remove server</string>
<string name="audiobookshelf_cancel">Cancel</string>
</resources> </resources>
+1 -1
View File
@@ -1,5 +1,5 @@
VERSION_MAJOR=3 VERSION_MAJOR=3
VERSION_MINOR=26 VERSION_MINOR=26
VERSION_PATCH=14 VERSION_PATCH=15
VERSION_SUFFIX=1 VERSION_SUFFIX=1
# 1 = Pre, 0 = Release # 1 = Pre, 0 = Release
+3 -3
View File
@@ -136,7 +136,7 @@ export default defineConfig({
], ],
}, },
socialLinks: [ socialLinks: [
{ icon: 'github', link: 'https://github.com/HapeLee/legado-with-MD3' }, { icon: 'github', link: 'https://gitea.yamby.cn/yusheng/QieKan-3.0' },
], ],
footer: { footer: {
message: '基于 Apache-2.0 许可发布', message: '基于 Apache-2.0 许可发布',
@@ -157,8 +157,8 @@ export default defineConfig({
text: '最后更新于', text: '最后更新于',
}, },
editLink: { editLink: {
pattern: 'https://github.com/HapeLee/legado-with-MD3/edit/main/docs/:path', pattern: 'https://gitea.yamby.cn/yusheng/QieKan-3.0/_edit/main/docs/:path',
text: '在 GitHub 上编辑此页面', text: '在 Gitea 上编辑此页面',
}, },
}, },
lastUpdated: true, lastUpdated: true,
@@ -0,0 +1,63 @@
# 且看有声书融合架构
## 决策
且看将 Audiobookshelf 作为独立的伴随服务端,通过原生 Android 客户端模块接入。
Node.js、Nuxt、Sequelize、服务端 SQLite、FFmpeg 和媒体扫描器不打包进 APK。
当前工作区中的 `.audiobookshelf` 仅用于协议核对和服务端 Fork 开发。正式维护时应将它
保留为独立仓库或 Git submodule,并使用可识别的 Fork 版本号,例如
`2.34.0-qiekan.1`
## 模块边界
- `:modules:audiobookshelf-client`
- Audiobookshelf REST 请求、DTO 和协议模型。
- 不依赖且看的 Room、Compose、主题或阅读领域。
- `app/domain/gateway/AudiobookGateway.kt``app/domain/model/AudiobookModels.kt`
- 服务器、媒体库、书籍、播放会话和进度的领域接口及模型。
- `app/data/audiobook`
- 领域仓库、服务器配置映射和安全令牌存储。
- `app/ui/audiobook`
- Compose、MVI/UDF、Navigation 3 页面。
- `app/service/audiobook`
- 后续阶段新增的 Media3 `MediaSessionService`
Audiobookshelf 条目不能映射为现有 `Book`/`BookChapter`,也不能直接写入旧版
`AudioPlay` 的全局状态。远端条目统一使用 `serverId + libraryItemId` 标识。
## 认证与安全
- API 使用独立的标准 OkHttpClient,不复用且看支持书源规则的宽松 TLS 客户端。
- 登录请求使用 `x-return-tokens: true`
- access token 仅保存在进程内存中。
- refresh token 使用 Android Keystore 的 AES/GCM 密钥加密,并写入
`noBackupFilesDir`,不进入且看备份。
- 401 刷新采用 single-flight,避免刷新令牌并发轮换。
- 服务器地址和用户名可随 `servers.json` 备份;密码和令牌不得进入配置 JSON。
- 公网地址默认使用 HTTPS。HTTP 仅用于用户明确配置的可信局域网环境。
## 导航与隐私
首版入口位于“我的 > 有声书”,不增加默认第六个底部导航项。后续可将有声书加入
主导航显示与排序设置,但默认保持隐藏。
后续接入且看首页“继续收听”、听书历史和统计时,必须在领域层统一应用隐私过滤,
而不是由各个组件自行过滤。隐藏书库的内容不得进入首页、历史、统计和锁屏元数据。
## 实施阶段
1. 连接与浏览:服务器配置、登录、令牌刷新、媒体库和书籍列表。
2. 播放:独立 Media3 Session、direct play、HLS、STRM、多音轨和进度同步。
3. 体验融合:继续收听、迷你播放器、书签、历史、主导航和隐私联动。
4. 离线:独立缓存数据库、断点续传、空间管理和离线进度回传。
5. 高级能力:Socket.IO、OIDC、播客和 115 动态换链或代理。
## 第一阶段验收
- 可连接带根路径的 Audiobookshelf 地址,例如 `/audiobookshelf`
- 可使用用户名和密码登录,并列出当前账号可访问的媒体库。
- 应用重启后可用加密 refresh token 恢复会话。
- refresh token 无效时保留服务器地址和用户名,并要求重新输入密码。
- WebDAV 服务器列表不显示 Audiobookshelf 类型的连接。
- 旧备份恢复流程不变,备份文件中不包含 Audiobookshelf 密码或令牌。
+2 -2
View File
@@ -57,5 +57,5 @@
## 外部资源 ## 外部资源
- [Legado GitHub](https://github.com/HapeLee/legado-with-MD3) — 项目源码和 Issue 跟踪 - [QieKan-3.0 Gitea](https://gitea.yamby.cn/yusheng/QieKan-3.0) — 项目源码和 Issue 跟踪
- [legado-with-MD3 Wiki](https://github.com/HapeLee/legado-with-MD3/wiki) — 社区维护的文档 - [QieKan-3.0 Wiki](https://gitea.yamby.cn/yusheng/QieKan-3.0/wiki) — 社区维护的文档
+12 -12
View File
@@ -22,7 +22,7 @@ Java 类和方法。本文档列出所有可用的内置变量、对象属性和
- `java` 变量已被 Legado 修改,调用 `java.*` 下的包请使用 `Packages.java.*` - `java` 变量已被 Legado 修改,调用 `java.*` 下的包请使用 `Packages.java.*`
- 在源规则中使用 `@js``<js>`、<code v-pre>{{}}</code> 可调用 Legado 内置的类和方法 - 在源规则中使用 `@js``<js>`、<code v-pre>{{}}</code> 可调用 Legado 内置的类和方法
- 为安全起见,部分 Java - 为安全起见,部分 Java
类调用被屏蔽,见 [RhinoClassShutter](https://github.com/HapeLee/legado-with-MD3/blob/master/modules/rhino/src/main/java/com/script/rhino/RhinoClassShutter.kt) 类调用被屏蔽,见 [RhinoClassShutter](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/modules/rhino/src/main/java/com/script/rhino/RhinoClassShutter.kt)
- 不同源规则中支持调用的 Java 类和方法可能不同 - 不同源规则中支持调用的 Java 类和方法可能不同
- `const` 声明的变量不支持块级作用域,循环中使用会出现值不变的问题,请改用 `var` - `const` 声明的变量不支持块级作用域,循环中使用会出现值不变的问题,请改用 `var`
::: :::
@@ -36,12 +36,12 @@ Java 类和方法。本文档列出所有可用的内置变量、对象属性和
| `java` | `Object` | 全局 | 扩展工具对象,提供网络请求、加解密、文件操作等方法 | | `java` | `Object` | 全局 | 扩展工具对象,提供网络请求、加解密、文件操作等方法 |
| `baseUrl` | `String` | 全局 | 当前请求的 URL | | `baseUrl` | `String` | 全局 | 当前请求的 URL |
| `result` | `Any` | 全局 | 上一步规则的执行结果 | | `result` | `Any` | 全局 | 上一步规则的执行结果 |
| `book` | `Book` | 全局 | [书籍对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/data/entities/Book.kt) | | `book` | `Book` | 全局 | [书籍对象](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/data/entities/Book.kt) |
| `rssArticle` | `RssArticle` | 全局 | [RSS 文章对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/data/entities/RssArticle.kt) | | `rssArticle` | `RssArticle` | 全局 | [RSS 文章对象](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/data/entities/RssArticle.kt) |
| `chapter` | `BookChapter` | 全局 | [章节对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/data/entities/BookChapter.kt) | | `chapter` | `BookChapter` | 全局 | [章节对象](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/data/entities/BookChapter.kt) |
| `source` | `BaseSource` | 全局 | [源对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/data/entities/BaseSource.kt) | | `source` | `BaseSource` | 全局 | [源对象](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/data/entities/BaseSource.kt) |
| `cookie` | `CookieStore` | 全局 | [Cookie 操作对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/http/CookieStore.kt) | | `cookie` | `CookieStore` | 全局 | [Cookie 操作对象](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/help/http/CookieStore.kt) |
| `cache` | `CacheManager` | 全局 | [缓存操作对象](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/CacheManager.kt) | | `cache` | `CacheManager` | 全局 | [缓存操作对象](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/help/CacheManager.kt) |
| `title` | `String` | 全局 | 当前章节标题 | | `title` | `String` | 全局 | 当前章节标题 |
| `src` | `String` | 全局 | 请求返回的源码 | | `src` | `String` | 全局 | 请求返回的源码 |
| `nextChapterUrl` | `String` | 全局 | 下一章节 URL | | `nextChapterUrl` | `String` | 全局 | 下一章节 URL |
@@ -50,7 +50,7 @@ Java 类和方法。本文档列出所有可用的内置变量、对象属性和
`java` 对象是 Legado 暴露给 JS 环境的核心工具对象,聚合了多个扩展类的方法。 `java` 对象是 Legado 暴露给 JS 环境的核心工具对象,聚合了多个扩展类的方法。
### 3.1 RSS 扩展 ([RssJsExtensions](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt)) ### 3.1 RSS 扩展 ([RssJsExtensions](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/ui/rss/read/RssJsExtensions.kt))
::: warning 作用域限制 ::: warning 作用域限制
只能在订阅源 `shouldOverrideUrlLoading` 规则中使用。URL 跳转拦截规则不能执行耗时操作。 只能在订阅源 `shouldOverrideUrlLoading` 规则中使用。URL 跳转拦截规则不能执行耗时操作。
@@ -61,7 +61,7 @@ java.searchBook(bookName: String) // 调用 Legado 搜索
java.addBook(bookUrl: String) // 添加书架 java.addBook(bookUrl: String) // 添加书架
``` ```
### 3.2 URL 解析 ([AnalyzeUrl](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt)) ### 3.2 URL 解析 ([AnalyzeUrl](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeUrl.kt))
通过 `java.` 调用,仅在 `登录检查 JS` 规则中有效。 通过 `java.` 调用,仅在 `登录检查 JS` 规则中有效。
@@ -72,7 +72,7 @@ java.addBook(bookUrl: String) // 添加书架
| `getStrResponse(jsStr, sourceRegex)` | `StrResponse` | 返回文本类型的访问结果 | | `getStrResponse(jsStr, sourceRegex)` | `StrResponse` | 返回文本类型的访问结果 |
| `getResponse()` | `Response` | 返回 Response 类型的访问结果 | | `getResponse()` | `Response` | 返回 Response 类型的访问结果 |
### 3.3 规则解析 ([AnalyzeRule](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt)) ### 3.3 规则解析 ([AnalyzeRule](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/model/analyzeRule/AnalyzeRule.kt))
```js ```js
// 获取文本/文本列表 // 获取文本/文本列表
@@ -95,7 +95,7 @@ java.get(key)
java.put(key, value) java.put(key, value)
``` ```
### 3.4 扩展工具 ([JsExtensions](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/JsExtensions.kt)) ### 3.4 扩展工具 ([JsExtensions](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/help/JsExtensions.kt))
#### 链接解析 #### 链接解析
@@ -198,7 +198,7 @@ cache.delete(java.md5Encode16(url)) // 使缓存失效
| `java.openUrl(url)` | 跳转外部链接(HTTP 或 scheme | | `java.openUrl(url)` | 跳转外部链接(HTTP 或 scheme |
| `java.openUrl(url, mimeType)` | 指定 MIME 类型跳转,如 `video/*` | | `java.openUrl(url, mimeType)` | 指定 MIME 类型跳转,如 `video/*` |
### 3.5 加解密 ([JsEncodeUtils](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/JsEncodeUtils.kt)) ### 3.5 加解密 ([JsEncodeUtils](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/help/JsEncodeUtils.kt))
提供在 JavaScript 环境中快捷调用 crypto 提供在 JavaScript 环境中快捷调用 crypto
算法的函数,由 [hutool-crypto](https://www.hutool.cn/docs/#/crypto/概述) 实现(当前版本 5.8.22)。 算法的函数,由 [hutool-crypto](https://www.hutool.cn/docs/#/crypto/概述) 实现(当前版本 5.8.22)。
+1 -1
View File
@@ -96,7 +96,7 @@ JavaScript 执行。
| 变量 | 说明 | | 变量 | 说明 |
|:---------|:-------------------------------------------------------------------------------------------------------------------------------------| |:---------|:-------------------------------------------------------------------------------------------------------------------------------------|
| `java` | 仅支持 [JsExtensions](https://github.com/HapeLee/legado-with-MD3/blob/master/app/src/main/java/io/legado/app/help/JsExtensions.kt) 中的方法 | | `java` | 仅支持 [JsExtensions](https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/help/JsExtensions.kt) 中的方法 |
| `result` | 待解密图片的 `ByteArray` | | `result` | 待解密图片的 `ByteArray` |
| `src` | 图片链接 | | `src` | 图片链接 |
+2 -2
View File
@@ -13,8 +13,8 @@ hero:
text: 开发文档 text: 开发文档
link: /dev/ link: /dev/
- theme: alt - theme: alt
text: GitHub text: Gitea
link: https://github.com/HapeLee/legado-with-MD3 link: https://gitea.yamby.cn/yusheng/QieKan-3.0
features: features:
- icon: 📖 - icon: 📖
@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.serialization)
}
android {
compileSdk = 37
namespace = "io.legado.audiobookshelf.client"
kotlin {
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
lint {
checkDependencies = true
targetSdk = 37
}
testOptions {
targetSdk = 37
}
}
dependencies {
api(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
testImplementation(libs.junit)
}
@@ -0,0 +1 @@
<manifest />
@@ -0,0 +1,170 @@
package io.legado.audiobookshelf.client
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
class AudiobookshelfApi(
private val client: OkHttpClient = OkHttpClient.Builder().build()
) {
fun getStatus(baseUrl: String): AudiobookshelfServerStatus {
val request = requestBuilder(baseUrl, "status").get().build()
val dto = execute<StatusDto>(request)
if (dto.app != AUDIOBOOKSHELF_APP) {
throw IllegalArgumentException("目标地址不是 Audiobookshelf 服务")
}
return AudiobookshelfServerStatus(
version = dto.serverVersion,
initialized = dto.isInit,
authMethods = dto.authMethods
)
}
fun login(baseUrl: String, username: String, password: String): AudiobookshelfAuthSession {
val body = json.encodeToString(LoginRequestDto(username, password))
.toRequestBody(JSON_MEDIA_TYPE)
val request = requestBuilder(baseUrl, "login")
.header(RETURN_TOKENS_HEADER, "true")
.post(body)
.build()
return execute<AuthResponseDto>(request).user.toSession()
}
fun refresh(baseUrl: String, refreshToken: String): AudiobookshelfAuthSession {
val request = requestBuilder(baseUrl, "auth/refresh")
.header(REFRESH_TOKEN_HEADER, refreshToken)
.post(EMPTY_BODY)
.build()
return execute<AuthResponseDto>(request).user.toSession()
}
fun getLibraries(baseUrl: String, accessToken: String): List<AudiobookshelfLibrary> {
val request = requestBuilder(baseUrl, "api/libraries")
.header(AUTHORIZATION, "Bearer $accessToken")
.get()
.build()
return execute<LibrariesResponseDto>(request).libraries.map { library ->
AudiobookshelfLibrary(
id = library.id,
name = library.name,
mediaType = library.mediaType,
icon = library.icon
)
}
}
private fun requestBuilder(baseUrl: String, path: String): Request.Builder {
return Request.Builder()
.url("${normalizeBaseUrl(baseUrl)}/${path.trimStart('/')}")
.header(ACCEPT, "application/json")
}
private inline fun <reified T> execute(request: Request): T {
client.newCall(request).execute().use { response ->
val responseBody = response.body.string()
if (!response.isSuccessful) {
throw AudiobookshelfApiException(
statusCode = response.code,
message = extractErrorMessage(responseBody)
?: "Audiobookshelf 请求失败(HTTP ${response.code}"
)
}
return runCatching { json.decodeFromString<T>(responseBody) }
.getOrElse { error ->
throw AudiobookshelfApiException(
statusCode = response.code,
message = "Audiobookshelf 返回了无法识别的数据:${error.message}"
)
}
}
}
private fun extractErrorMessage(body: String): String? {
return runCatching {
val root = json.parseToJsonElement(body).jsonObject
root["error"]?.jsonPrimitive?.contentOrNull
?: root["message"]?.jsonPrimitive?.contentOrNull
}.getOrNull()?.takeIf { it.isNotBlank() }
}
companion object {
private const val AUDIOBOOKSHELF_APP = "audiobookshelf"
private const val ACCEPT = "Accept"
private const val AUTHORIZATION = "Authorization"
private const val RETURN_TOKENS_HEADER = "x-return-tokens"
private const val REFRESH_TOKEN_HEADER = "x-refresh-token"
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
private val EMPTY_BODY = ByteArray(0).toRequestBody(null)
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
}
fun normalizeBaseUrl(input: String): String {
val value = input.trim().trimEnd('/')
val parsed = value.toHttpUrlOrNull()
?: throw IllegalArgumentException("请输入完整的 HTTP 或 HTTPS 服务器地址")
require(parsed.query == null && parsed.fragment == null) {
"服务器地址不能包含查询参数或片段"
}
return parsed.toString().trimEnd('/')
}
}
}
@Serializable
private data class StatusDto(
val app: String,
val serverVersion: String = "",
val isInit: Boolean = false,
val authMethods: List<String> = emptyList()
)
@Serializable
private data class LoginRequestDto(
val username: String,
val password: String
)
@Serializable
private data class AuthResponseDto(
val user: AuthUserDto
)
@Serializable
private data class AuthUserDto(
val username: String = "",
val accessToken: String,
val refreshToken: String
) {
fun toSession(): AudiobookshelfAuthSession {
return AudiobookshelfAuthSession(
username = username,
accessToken = accessToken,
refreshToken = refreshToken
)
}
}
@Serializable
private data class LibrariesResponseDto(
val libraries: List<LibraryDto> = emptyList()
)
@Serializable
private data class LibraryDto(
val id: String,
val name: String,
val mediaType: String,
@SerialName("icon") val icon: String? = null
)
@@ -0,0 +1,6 @@
package io.legado.audiobookshelf.client
class AudiobookshelfApiException(
val statusCode: Int,
message: String
) : Exception(message)
@@ -0,0 +1,20 @@
package io.legado.audiobookshelf.client
data class AudiobookshelfServerStatus(
val version: String,
val initialized: Boolean,
val authMethods: List<String>
)
data class AudiobookshelfAuthSession(
val username: String,
val accessToken: String,
val refreshToken: String
)
data class AudiobookshelfLibrary(
val id: String,
val name: String,
val mediaType: String,
val icon: String?
)
@@ -0,0 +1,30 @@
package io.legado.audiobookshelf.client
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Test
class AudiobookshelfApiTest {
@Test
fun normalizeBaseUrl_keepsRouterBasePathAndRemovesTrailingSlash() {
assertEquals(
"https://example.com/audiobookshelf",
AudiobookshelfApi.normalizeBaseUrl(" https://example.com/audiobookshelf/ ")
)
}
@Test
fun normalizeBaseUrl_rejectsMissingScheme() {
assertThrows(IllegalArgumentException::class.java) {
AudiobookshelfApi.normalizeBaseUrl("example.com:2048")
}
}
@Test
fun normalizeBaseUrl_rejectsQueryParameters() {
assertThrows(IllegalArgumentException::class.java) {
AudiobookshelfApi.normalizeBaseUrl("https://example.com/?token=secret")
}
}
}
+1
View File
@@ -41,6 +41,7 @@ rootProject.name = 'legado'
include ':app' include ':app'
include ':modules:book' include ':modules:book'
include ':modules:audiobookshelf-client'
include ':modules:rhino' include ':modules:rhino'
include ':baselineprofile' include ':baselineprofile'
project(':baselineprofile').projectDir = file('baselineProfile') project(':baselineprofile').projectDir = file('baselineProfile')