diff --git a/.gitignore b/.gitignore
index 7c8304de1..d14b2a99a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,6 @@ node_modules/
/.vscode/settings.json
/.codex/skills
/.backup/
+/.audiobookshelf/
+/.gradle-codex/
+/.tools/
diff --git a/English.md b/English.md
index 2e6429d92..8ba1bd025 100644
--- a/English.md
+++ b/English.md
@@ -1,8 +1,8 @@
# Legado with MD3
- 简体中文 |
- English
+ 简体中文 |
+ English
---
diff --git a/README.md b/README.md
index b810d8f35..ea79571f3 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,8 @@
# Legado with MD3
- 简体中文 |
- English
+ 简体中文 |
+ English
## 📖 介绍
@@ -50,7 +50,7 @@ Design 3 风格重构版本。
* **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)
下载。
---
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 0865e1e46..b1b6083ad 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -51,7 +51,7 @@ android {
applicationId = "io.legato.kazusa"
minSdk = 26
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
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -225,6 +225,7 @@ dependencies {
implementation(libs.jsoupxpath)
implementation(libs.intellij.markdown)
implementation(project(":modules:book"))
+ implementation(project(":modules:audiobookshelf-client"))
implementation(project(":modules:rhino"))
implementation(libs.okhttp)
implementation(fileTree(mapOf("dir" to "cronetlib", "include" to listOf("*.jar", "*.aar"))))
diff --git a/app/src/androidTest/java/io/legado/app/UpdateTest.kt b/app/src/androidTest/java/io/legado/app/UpdateTest.kt
index 696eec31e..14551d033 100644
--- a/app/src/androidTest/java/io/legado/app/UpdateTest.kt
+++ b/app/src/androidTest/java/io/legado/app/UpdateTest.kt
@@ -3,8 +3,8 @@ package io.legado.app
import com.google.gson.Gson
import io.legado.app.exception.NoStackTraceException
import io.legado.app.help.http.okHttpClient
-import io.legado.app.help.update.GithubRelease
-import io.legado.app.utils.fromJsonObject
+import io.legado.app.help.update.GiteaRelease
+import io.legado.app.utils.fromJsonArray
import okhttp3.Request
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -12,24 +12,25 @@ import org.junit.Test
class UpdateTest {
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 =
- "https://api.github.com/repos/gedoor/legado/releases/tags/beta"
+ "https://gitea.yamby.cn/api/v1/repos/yusheng/QieKan-3.0/releases"
@Test
fun updateApp_beta() {
val body = okHttpClient.newCall(Request.Builder().url(lastBetaReleaseUrl).build()).execute()
.body!!.string()
- val releaseList = Gson().fromJsonObject(body)
+ val releaseList = Gson().fromJsonArray(body)
.getOrElse {
throw NoStackTraceException("获取新版本出错 " + it.localizedMessage)
}
- .gitReleaseToAppReleaseInfo()
+ .filter { it.isPreRelease }
+ .flatMap { it.toAppReleaseInfo() }
.sortedByDescending { it.createdAt }
- assertTrue(releaseList.size == 2)
+ assertTrue(releaseList.isNotEmpty())
assertTrue(releaseList.all { it.downloadUrl.isNotBlank() })
assertTrue(releaseList.all { it.versionName.isNotBlank() })
}
@@ -39,16 +40,17 @@ class UpdateTest {
val body = okHttpClient.newCall(Request.Builder().url(lastReleaseUrl).build()).execute()
.body!!.string()
- val releaseList = Gson().fromJsonObject(body)
+ val releaseList = Gson().fromJsonArray(body)
.getOrElse {
throw NoStackTraceException("获取新版本出错 " + it.localizedMessage)
}
- .gitReleaseToAppReleaseInfo()
+ .filter { !it.isPreRelease }
+ .flatMap { it.toAppReleaseInfo() }
.sortedByDescending { it.createdAt }
- assertTrue(releaseList.size == 1)
+ assertTrue(releaseList.isNotEmpty())
assertTrue(releaseList.all { it.downloadUrl.isNotBlank() })
assertTrue(releaseList.all { it.versionName.isNotBlank() })
}
-}
\ No newline at end of file
+}
diff --git a/app/src/appNoR8/generated/baselineProfiles/baseline-prof.txt b/app/src/appNoR8/generated/baselineProfiles/baseline-prof.txt
index 038d94075..9e15b2889 100644
--- a/app/src/appNoR8/generated/baselineProfiles/baseline-prof.txt
+++ b/app/src/appNoR8/generated/baselineProfiles/baseline-prof.txt
@@ -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;->()V
Lio/legado/app/help/update/AppUpdate$AppUpdateInterface;
Lio/legado/app/help/update/AppUpdate$UpdateInfo;
-Lio/legado/app/help/update/AppUpdateGitHub;
-HSPLio/legado/app/help/update/AppUpdateGitHub;->()V
-HSPLio/legado/app/help/update/AppUpdateGitHub;->()V
-HSPLio/legado/app/help/update/AppUpdateGitHub;->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;
-Lio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;
-HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->(Ljava/lang/String;)V
-HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
-Lio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;
-HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->(Lio/legado/app/help/update/AppUpdateGitHub;Lkotlin/coroutines/Continuation;)V
-HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea;
+HSPLio/legado/app/help/update/AppUpdateGitea;->()V
+HSPLio/legado/app/help/update/AppUpdateGitea;->()V
+HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit;
+HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;
+HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->(Ljava/lang/String;)V
+HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;
+HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->(Lio/legado/app/help/update/AppUpdateGitea;Lkotlin/coroutines/Continuation;)V
+HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
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;->()V
@@ -31483,4 +31483,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
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;->()V
-HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->(Ljava/lang/String;I)V
\ No newline at end of file
+HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->(Ljava/lang/String;I)V
diff --git a/app/src/appNoR8/generated/baselineProfiles/startup-prof.txt b/app/src/appNoR8/generated/baselineProfiles/startup-prof.txt
index 038d94075..9e15b2889 100644
--- a/app/src/appNoR8/generated/baselineProfiles/startup-prof.txt
+++ b/app/src/appNoR8/generated/baselineProfiles/startup-prof.txt
@@ -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;->()V
Lio/legado/app/help/update/AppUpdate$AppUpdateInterface;
Lio/legado/app/help/update/AppUpdate$UpdateInfo;
-Lio/legado/app/help/update/AppUpdateGitHub;
-HSPLio/legado/app/help/update/AppUpdateGitHub;->()V
-HSPLio/legado/app/help/update/AppUpdateGitHub;->()V
-HSPLio/legado/app/help/update/AppUpdateGitHub;->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;
-Lio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;
-HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->(Ljava/lang/String;)V
-HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
-Lio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;
-HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->(Lio/legado/app/help/update/AppUpdateGitHub;Lkotlin/coroutines/Continuation;)V
-HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea;
+HSPLio/legado/app/help/update/AppUpdateGitea;->()V
+HSPLio/legado/app/help/update/AppUpdateGitea;->()V
+HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit;
+HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;
+HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->(Ljava/lang/String;)V
+HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;
+HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->(Lio/legado/app/help/update/AppUpdateGitea;Lkotlin/coroutines/Continuation;)V
+HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
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;->()V
@@ -31483,4 +31483,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
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;->()V
-HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->(Ljava/lang/String;I)V
\ No newline at end of file
+HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->(Ljava/lang/String;I)V
diff --git a/app/src/appRelease/generated/baselineProfiles/baseline-prof.txt b/app/src/appRelease/generated/baselineProfiles/baseline-prof.txt
index 038d94075..9e15b2889 100644
--- a/app/src/appRelease/generated/baselineProfiles/baseline-prof.txt
+++ b/app/src/appRelease/generated/baselineProfiles/baseline-prof.txt
@@ -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;->()V
Lio/legado/app/help/update/AppUpdate$AppUpdateInterface;
Lio/legado/app/help/update/AppUpdate$UpdateInfo;
-Lio/legado/app/help/update/AppUpdateGitHub;
-HSPLio/legado/app/help/update/AppUpdateGitHub;->()V
-HSPLio/legado/app/help/update/AppUpdateGitHub;->()V
-HSPLio/legado/app/help/update/AppUpdateGitHub;->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;
-Lio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;
-HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->(Ljava/lang/String;)V
-HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
-Lio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;
-HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->(Lio/legado/app/help/update/AppUpdateGitHub;Lkotlin/coroutines/Continuation;)V
-HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea;
+HSPLio/legado/app/help/update/AppUpdateGitea;->()V
+HSPLio/legado/app/help/update/AppUpdateGitea;->()V
+HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit;
+HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;
+HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->(Ljava/lang/String;)V
+HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;
+HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->(Lio/legado/app/help/update/AppUpdateGitea;Lkotlin/coroutines/Continuation;)V
+HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
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;->()V
@@ -31483,4 +31483,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
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;->()V
-HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->(Ljava/lang/String;I)V
\ No newline at end of file
+HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->(Ljava/lang/String;I)V
diff --git a/app/src/appRelease/generated/baselineProfiles/startup-prof.txt b/app/src/appRelease/generated/baselineProfiles/startup-prof.txt
index 038d94075..9e15b2889 100644
--- a/app/src/appRelease/generated/baselineProfiles/startup-prof.txt
+++ b/app/src/appRelease/generated/baselineProfiles/startup-prof.txt
@@ -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;->()V
Lio/legado/app/help/update/AppUpdate$AppUpdateInterface;
Lio/legado/app/help/update/AppUpdate$UpdateInfo;
-Lio/legado/app/help/update/AppUpdateGitHub;
-HSPLio/legado/app/help/update/AppUpdateGitHub;->()V
-HSPLio/legado/app/help/update/AppUpdateGitHub;->()V
-HSPLio/legado/app/help/update/AppUpdateGitHub;->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;
-Lio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;
-HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->(Ljava/lang/String;)V
-HSPLio/legado/app/help/update/AppUpdateGitHub$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
-Lio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;
-HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->(Lio/legado/app/help/update/AppUpdateGitHub;Lkotlin/coroutines/Continuation;)V
-HSPLio/legado/app/help/update/AppUpdateGitHub$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea;
+HSPLio/legado/app/help/update/AppUpdateGitea;->()V
+HSPLio/legado/app/help/update/AppUpdateGitea;->()V
+HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag$lambda$0(Ljava/lang/String;Lokhttp3/Request$Builder;)Lkotlin/Unit;
+HSPLio/legado/app/help/update/AppUpdateGitea;->getReleaseByTag(Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;
+HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->(Ljava/lang/String;)V
+HSPLio/legado/app/help/update/AppUpdateGitea$$ExternalSyntheticLambda0;->invoke(Ljava/lang/Object;)Ljava/lang/Object;
+Lio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;
+HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->(Lio/legado/app/help/update/AppUpdateGitea;Lkotlin/coroutines/Continuation;)V
+HSPLio/legado/app/help/update/AppUpdateGitea$getReleaseByTag$1;->invokeSuspend(Ljava/lang/Object;)Ljava/lang/Object;
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;->()V
@@ -31483,4 +31483,4 @@ HSPLtop/yukonga/miuix/kmp/theme/MiuixTheme;->getColorScheme(Landroidx/compose/ru
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;->()V
-HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->(Ljava/lang/String;I)V
\ No newline at end of file
+HSPLtop/yukonga/miuix/kmp/theme/ThemePaletteStyle;->(Ljava/lang/String;I)V
diff --git a/app/src/main/java/io/legado/app/constant/PreferKey.kt b/app/src/main/java/io/legado/app/constant/PreferKey.kt
index 8a671f04b..1ed6cdf4e 100644
--- a/app/src/main/java/io/legado/app/constant/PreferKey.kt
+++ b/app/src/main/java/io/legado/app/constant/PreferKey.kt
@@ -264,6 +264,8 @@ object PreferKey {
const val paddingDisplayCutouts = "paddingDisplayCutouts"
const val autoCheckNewBackup = "autoCheckNewBackup"
const val backupSyncMode = "backupSyncMode"
+ const val backupFonts = "backupFonts"
+ const val backupLocalBooks = "backupLocalBooks"
const val bookshelfLayoutGridLandscape = "bookshelfLayoutGridLandscape"
const val bookshelfLayoutGridPortrait = "bookshelfLayoutGridPortrait"
const val bookshelfLayoutListLandscape = "bookshelfLayoutListLandscape"
diff --git a/app/src/main/java/io/legado/app/data/audiobook/AudiobookRepository.kt b/app/src/main/java/io/legado/app/data/audiobook/AudiobookRepository.kt
new file mode 100644
index 000000000..b08eecbb7
--- /dev/null
+++ b/app/src/main/java/io/legado/app/data/audiobook/AudiobookRepository.kt
@@ -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()
+ 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
+ ): AudiobookHomeSnapshot {
+ return AudiobookHomeSnapshot(
+ profile = profile,
+ serverVersion = serverVersion,
+ libraries = libraries.map { library ->
+ AudiobookLibrarySummary(
+ id = library.id,
+ name = library.name,
+ mediaType = library.mediaType,
+ icon = library.icon
+ )
+ }
+ )
+ }
+}
diff --git a/app/src/main/java/io/legado/app/data/audiobook/AudiobookTokenStore.kt b/app/src/main/java/io/legado/app/data/audiobook/AudiobookTokenStore.kt
new file mode 100644
index 000000000..6f8550f10
--- /dev/null
+++ b/app/src/main/java/io/legado/app/data/audiobook/AudiobookTokenStore.kt
@@ -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
+ }
+}
diff --git a/app/src/main/java/io/legado/app/data/entities/Server.kt b/app/src/main/java/io/legado/app/data/entities/Server.kt
index c8d580729..94dfeb4b3 100644
--- a/app/src/main/java/io/legado/app/data/entities/Server.kt
+++ b/app/src/main/java/io/legado/app/data/entities/Server.kt
@@ -23,7 +23,8 @@ data class Server(
) : Parcelable {
enum class TYPE {
- WEBDAV
+ WEBDAV,
+ AUDIOBOOKSHELF
}
override fun hashCode(): Int {
@@ -47,6 +48,14 @@ data class Server(
return if (type == TYPE.WEBDAV) GSON.fromJsonObject(config).getOrNull() else null
}
+ fun getAudiobookshelfConfig(): AudiobookshelfConfig? {
+ return if (type == TYPE.AUDIOBOOKSHELF) {
+ GSON.fromJsonObject(config).getOrNull()
+ } else {
+ null
+ }
+ }
+
@Parcelize
data class WebDavConfig(
var url: String,
@@ -54,4 +63,10 @@ data class Server(
var password: String
) : Parcelable
-}
\ No newline at end of file
+ @Parcelize
+ data class AudiobookshelfConfig(
+ var url: String,
+ var username: String
+ ) : Parcelable
+
+}
diff --git a/app/src/main/java/io/legado/app/data/repository/RemoteBookRepository.kt b/app/src/main/java/io/legado/app/data/repository/RemoteBookRepository.kt
index 046328928..849f0a6c8 100644
--- a/app/src/main/java/io/legado/app/data/repository/RemoteBookRepository.kt
+++ b/app/src/main/java/io/legado/app/data/repository/RemoteBookRepository.kt
@@ -18,6 +18,7 @@ import io.legado.app.model.remote.RemoteBook
import io.legado.app.model.remote.RemoteBookWebDav
import io.legado.app.utils.isContentScheme
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
class RemoteBookRepository(
private val appDb: AppDatabase
@@ -126,7 +127,9 @@ class RemoteBookRepository(
}
fun flowServers(): Flow> {
- return appDb.serverDao.observeAll()
+ return appDb.serverDao.observeAll().map { servers ->
+ servers.filter { it.type == Server.TYPE.WEBDAV }
+ }
}
suspend fun getServer(id: Long): Server? {
diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt
index 33f8de7a6..cab3cce5d 100644
--- a/app/src/main/java/io/legado/app/di/appModule.kt
+++ b/app/src/main/java/io/legado/app/di/appModule.kt
@@ -5,6 +5,9 @@ import coil.ImageLoader
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
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.local.preferences.LocalPreferencesRepository
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.AiTextGateway
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.BackupRestoreGateway
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.ui.about.AboutViewModel
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.cache.manage.BookCacheManageViewModel
import io.legado.app.ui.book.changecover.ChangeCoverViewModel
@@ -227,6 +232,10 @@ val appModule = module {
singleOf(::BookshelfManageScreenConfig)
singleOf(::ThemePackageManager)
+ single { AudiobookshelfApi() }
+ single { AudiobookTokenStore(get()) }
+ single { AudiobookRepository(get(), get(), get()) }
+
single { DirectLinkUploadRepository() }
single { TranslationCacheRepositoryImpl() }
single { AiProfileRepository(get()) }
@@ -315,6 +324,7 @@ val appModule = module {
viewModelOf(::BackupConfigViewModel)
viewModelOf(::AiConfigViewModel)
viewModelOf(::AiChatViewModel)
+ viewModelOf(::AudiobookViewModel)
viewModel { (providerId: String?) ->
AiProviderEditViewModel(
initialProviderId = providerId,
diff --git a/app/src/main/java/io/legado/app/domain/gateway/AudiobookGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/AudiobookGateway.kt
new file mode 100644
index 000000000..5fa3dc48e
--- /dev/null
+++ b/app/src/main/java/io/legado/app/domain/gateway/AudiobookGateway.kt
@@ -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)
diff --git a/app/src/main/java/io/legado/app/domain/model/AudiobookModels.kt b/app/src/main/java/io/legado/app/domain/model/AudiobookModels.kt
new file mode 100644
index 000000000..46fe47a4a
--- /dev/null
+++ b/app/src/main/java/io/legado/app/domain/model/AudiobookModels.kt
@@ -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
+)
diff --git a/app/src/main/java/io/legado/app/help/book/BookExtensions.kt b/app/src/main/java/io/legado/app/help/book/BookExtensions.kt
index ef5574f35..94cf99cd2 100644
--- a/app/src/main/java/io/legado/app/help/book/BookExtensions.kt
+++ b/app/src/main/java/io/legado/app/help/book/BookExtensions.kt
@@ -198,6 +198,7 @@ fun Book.getLocalUri(): Uri {
val newBook = oldBook.copy(bookUrl = newBookUrl)
appDb.bookDao.replace(oldBook, newBook)
BookHelp.updateCacheFolder(oldBook, newBook)
+ appDb.readRecordDao.replaceBookUrl(oldBook.bookUrl, newBookUrl)
this.bookUrl = newBookUrl
}
}
@@ -230,6 +231,7 @@ fun Book.getLocalUri(): Uri {
val newBook = oldBook.copy(bookUrl = newBookUrl)
appDb.bookDao.replace(oldBook, newBook)
BookHelp.updateCacheFolder(oldBook, newBook)
+ appDb.readRecordDao.replaceBookUrl(oldBook.bookUrl, newBookUrl)
this.bookUrl = newBookUrl
}
}
@@ -244,13 +246,22 @@ fun Book.getLocalUri(): Uri {
fun Book.getArchiveUri(): Uri? {
- val defaultBookDir = OtherConfig.defaultBookTreeUri
- return if (isArchive && !defaultBookDir.isNullOrBlank()) {
- FileDoc.fromUri(defaultBookDir.toUri(), true)
- .find(archiveName)?.uri
- } else {
- null
+ if (!isArchive) return null
+ val archiveFileName = archiveName
+ val searchDirs = listOfNotNull(
+ OtherConfig.defaultBookTreeUri,
+ AppConfig.importBookPath
+ ).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) {
diff --git a/app/src/main/java/io/legado/app/help/config/AppConfig.kt b/app/src/main/java/io/legado/app/help/config/AppConfig.kt
index e25fe2b92..8af1309c2 100644
--- a/app/src/main/java/io/legado/app/help/config/AppConfig.kt
+++ b/app/src/main/java/io/legado/app/help/config/AppConfig.kt
@@ -565,6 +565,10 @@ object AppConfig : SharedPreferences.OnSharedPreferenceChangeListener {
val onlyLatestBackup get() = BackupConfig.onlyLatestBackup
+ val backupFonts get() = BackupConfig.backupFonts
+
+ val backupLocalBooks get() = BackupConfig.backupLocalBooks
+
val autoCheckNewBackup get() = ThemeConfig.autoCheckNewBackup
val defaultHomePage get() = ThemeConfig.defaultHomePage
diff --git a/app/src/main/java/io/legado/app/help/storage/Backup.kt b/app/src/main/java/io/legado/app/help/storage/Backup.kt
index 41959ddd9..189d73fb6 100644
--- a/app/src/main/java/io/legado/app/help/storage/Backup.kt
+++ b/app/src/main/java/io/legado/app/help/storage/Backup.kt
@@ -15,18 +15,26 @@ import io.legado.app.help.config.AppConfig
import io.legado.app.help.config.LocalConfig
import io.legado.app.help.config.ThemeConfigStore
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.data.repository.HighlightRuleRepository
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.GSON
import io.legado.app.utils.LogUtils
+import io.legado.app.utils.MD5Utils
import io.legado.app.utils.compress.ZipUtils
import io.legado.app.utils.createFolderIfNotExist
+import io.legado.app.utils.defaultSharedPreferences
import io.legado.app.data.repository.dataStore
import io.legado.app.utils.externalFiles
import kotlinx.coroutines.flow.first
import io.legado.app.utils.getFile
import io.legado.app.utils.isContentScheme
+import io.legado.app.utils.inputStream
import io.legado.app.utils.normalizeFileName
import io.legado.app.utils.openOutputStream
import io.legado.app.utils.outputStream
@@ -56,6 +64,11 @@ object 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 {
arrayOf(
"bookshelf.json",
@@ -65,6 +78,7 @@ object Backup {
"rssSources.json",
"rssStar.json",
"replaceRule.json",
+ HighlightRuleRepository.backupFileName,
"readRecord.json",
"readRecordDetail.json",
"readRecordSession.json",
@@ -132,6 +146,14 @@ object Backup {
LocalConfig.lastBackup = System.currentTimeMillis()
val aes = BackupAES()
FileUtils.delete(backupPath)
+ //打包自定义字体与本地书籍(在写入书架前执行,本地书籍查找会同步更新书架中的路径)
+ val extraBackupPaths = arrayListOf()
+ 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.bookmarkDao.all, "bookmark.json", backupPath)
writeListToJson(appDb.bookGroupDao.all, "bookGroup.json", backupPath)
@@ -139,6 +161,24 @@ object Backup {
writeListToJson(appDb.rssSourceDao.all, "rssSources.json", backupPath)
writeListToJson(appDb.rssStarDao.all, "rssStar.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.allDetail, "readRecordDetail.json", backupPath)
writeListToJson(appDb.readRecordDao.allSession, "readRecordSession.json", backupPath)
@@ -209,6 +249,7 @@ object Backup {
for (i in 0 until paths.size) {
paths[i] = backupPath + File.separator + paths[i]
}
+ paths.addAll(extraBackupPaths)
FileUtils.delete(zipFilePath)
FileUtils.delete(zipFilePath.replace("tmp_", ""))
val backupFileName = if (AppConfig.onlyLatestBackup) {
@@ -270,6 +311,125 @@ object Backup {
}
}
+ /**
+ * 备份自定义字体, 返回打包目录路径
+ */
+ private fun backupFontsTo(backupDir: File): String? = kotlin.runCatching {
+ val fontPathSet = hashSetOf()
+ 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()
+ 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()
+ 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)
@Suppress("SameParameterValue")
private fun copyBackup(context: Context, uri: Uri, fileName: String) {
diff --git a/app/src/main/java/io/legado/app/help/storage/BackupConfig.kt b/app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
index 32537bec8..81736c370 100644
--- a/app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
+++ b/app/src/main/java/io/legado/app/help/storage/BackupConfig.kt
@@ -79,10 +79,14 @@ object BackupConfig {
PreferKey.clickActionMR,
PreferKey.clickActionBL,
PreferKey.clickActionBC,
- PreferKey.clickActionBR
+ PreferKey.clickActionBR,
+ PreferKey.highlightRuleDialog,
+ PreferKey.highlightRuleBookTitle,
+ PreferKey.highlightRuleBracketNote
)
private val themePrefKeys = arrayOf(
+ PreferKey.appFontPath,
PreferKey.cPrimary,
PreferKey.cNPrimary,
PreferKey.bgImage,
diff --git a/app/src/main/java/io/legado/app/help/storage/Restore.kt b/app/src/main/java/io/legado/app/help/storage/Restore.kt
index 39dee962e..79e7e0f51 100644
--- a/app/src/main/java/io/legado/app/help/storage/Restore.kt
+++ b/app/src/main/java/io/legado/app/help/storage/Restore.kt
@@ -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.ReadRecordDetail
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.help.DirectLinkUpload
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.upType
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.model.BookCover
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.FileDoc
import io.legado.app.utils.FileUtils
import io.legado.app.utils.GSON
import io.legado.app.utils.LogUtils
import io.legado.app.utils.compress.ZipUtils
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.getPrefString
import io.legado.app.utils.isContentScheme
@@ -341,17 +349,45 @@ object Restore : KoinComponent {
}
// 恢复配置文件 (手动解析 XML,替代反射逻辑)
val configFile = File(path, "config.xml")
+ var restoredConfigMap: Map = emptyMap()
if (configFile.exists()) {
try {
- val map = readXmlToMap(configFile)
- if (map.isNotEmpty()) {
- applyConfigMap(map, aes)
+ restoredConfigMap = readXmlToMap(configFile)
+ if (restoredConfigMap.isNotEmpty()) {
+ applyConfigMap(restoredConfigMap, aes)
}
} catch (e: Exception) {
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)
withContext(Main) {
delay(100)
@@ -362,6 +398,160 @@ object Restore : KoinComponent {
}
}
+ /**
+ * 恢复高亮规则及其开关配置。旧备份没有该文件时直接跳过。
+ */
+ private fun restoreHighlightRules(file: File) {
+ if (!file.exists()) return
+ val backupData = GSON.fromJsonObject(
+ 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