From d2d2ecd3bc0254341cb606ac8c61a37333feefeb Mon Sep 17 00:00:00 2001 From: yusheng <1+yusheng@noreply.localhost> Date: Sat, 8 Aug 2026 12:22:26 +0800 Subject: [PATCH] feat: release QieKan 3.26.15 --- .gitignore | 3 + English.md | 4 +- README.md | 6 +- app/build.gradle.kts | 3 +- .../java/io/legado/app/UpdateTest.kt | 24 +- .../baselineProfiles/baseline-prof.txt | 24 +- .../baselineProfiles/startup-prof.txt | 24 +- .../baselineProfiles/baseline-prof.txt | 24 +- .../baselineProfiles/startup-prof.txt | 24 +- .../java/io/legado/app/constant/PreferKey.kt | 2 + .../app/data/audiobook/AudiobookRepository.kt | 179 ++++++++++ .../app/data/audiobook/AudiobookTokenStore.kt | 89 +++++ .../io/legado/app/data/entities/Server.kt | 19 +- .../data/repository/RemoteBookRepository.kt | 5 +- .../main/java/io/legado/app/di/appModule.kt | 10 + .../app/domain/gateway/AudiobookGateway.kt | 27 ++ .../app/domain/model/AudiobookModels.kt | 21 ++ .../io/legado/app/help/book/BookExtensions.kt | 23 +- .../io/legado/app/help/config/AppConfig.kt | 4 + .../java/io/legado/app/help/storage/Backup.kt | 160 +++++++++ .../legado/app/help/storage/BackupConfig.kt | 6 +- .../io/legado/app/help/storage/Restore.kt | 196 ++++++++++- .../legado/app/help/update/AppReleaseInfo.kt | 35 +- .../io/legado/app/help/update/AppUpdate.kt | 4 +- .../legado/app/help/update/AppUpdateGitHub.kt | 62 ++-- .../io/legado/app/ui/about/AboutScreen.kt | 7 +- .../io/legado/app/ui/about/AboutViewModel.kt | 11 +- .../legado/app/ui/about/MiuixAboutScreen.kt | 5 +- .../app/ui/audiobook/AudiobookContract.kt | 51 +++ .../app/ui/audiobook/AudiobookScreen.kt | 326 ++++++++++++++++++ .../app/ui/audiobook/AudiobookViewModel.kt | 160 +++++++++ .../ui/config/backupConfig/BackupConfig.kt | 10 + .../config/backupConfig/BackupConfigScreen.kt | 14 + .../io/legado/app/ui/main/MainActivity.kt | 4 +- .../io/legado/app/ui/main/MainNavGraph.kt | 8 + .../java/io/legado/app/ui/main/MainNavKey.kt | 3 + .../io/legado/app/ui/main/MainNavigator.kt | 1 + .../java/io/legado/app/ui/main/MainScreen.kt | 2 + .../java/io/legado/app/ui/main/my/MyScreen.kt | 8 + app/src/main/res/values-zh-rCN/strings.xml | 31 +- app/src/main/res/values-zh-rHK/strings.xml | 3 +- app/src/main/res/values-zh-rTW/strings.xml | 3 +- app/src/main/res/values/non_translat.xml | 6 +- app/src/main/res/values/strings.xml | 31 +- app/version.properties | 2 +- docs/.vitepress/config.ts | 6 +- docs/architecture/audiobook-integration.md | 63 ++++ docs/dev/index.md | 4 +- docs/dev/js.md | 24 +- docs/dev/rule.md | 2 +- docs/index.md | 4 +- .../audiobookshelf-client/build.gradle.kts | 40 +++ .../src/main/AndroidManifest.xml | 1 + .../client/AudiobookshelfApi.kt | 170 +++++++++ .../client/AudiobookshelfApiException.kt | 6 + .../client/AudiobookshelfModels.kt | 20 ++ .../client/AudiobookshelfApiTest.kt | 30 ++ settings.gradle | 1 + 58 files changed, 1863 insertions(+), 172 deletions(-) create mode 100644 app/src/main/java/io/legado/app/data/audiobook/AudiobookRepository.kt create mode 100644 app/src/main/java/io/legado/app/data/audiobook/AudiobookTokenStore.kt create mode 100644 app/src/main/java/io/legado/app/domain/gateway/AudiobookGateway.kt create mode 100644 app/src/main/java/io/legado/app/domain/model/AudiobookModels.kt create mode 100644 app/src/main/java/io/legado/app/ui/audiobook/AudiobookContract.kt create mode 100644 app/src/main/java/io/legado/app/ui/audiobook/AudiobookScreen.kt create mode 100644 app/src/main/java/io/legado/app/ui/audiobook/AudiobookViewModel.kt create mode 100644 docs/architecture/audiobook-integration.md create mode 100644 modules/audiobookshelf-client/build.gradle.kts create mode 100644 modules/audiobookshelf-client/src/main/AndroidManifest.xml create mode 100644 modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfApi.kt create mode 100644 modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfApiException.kt create mode 100644 modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfModels.kt create mode 100644 modules/audiobookshelf-client/src/test/java/io/legado/audiobookshelf/client/AudiobookshelfApiTest.kt 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>(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>(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, aes: BackupAES) { val finalMap = mutableMapOf() appCtx.defaultSharedPreferences.edit { diff --git a/app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt b/app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt index 1c0861b1b..643ec2c92 100644 --- a/app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt +++ b/app/src/main/java/io/legado/app/help/update/AppReleaseInfo.kt @@ -3,7 +3,6 @@ package io.legado.app.help.update import android.os.Build import androidx.annotation.Keep import com.google.gson.annotations.SerializedName -import io.legado.app.exception.NoStackTraceException import java.time.Instant data class AppReleaseInfo( @@ -25,9 +24,11 @@ enum class AppVariant { } @Keep -data class GithubRelease( - val assets: List?, - val body: String, +data class GiteaRelease( + val assets: List?, + val body: String?, + @SerializedName("draft") + val isDraft: Boolean = false, @SerializedName("prerelease") val isPreRelease: Boolean, @SerializedName("tag_name") @@ -36,8 +37,8 @@ data class GithubRelease( @SerializedName("created_at") val createdAt: String? ) { - fun gitReleaseToAppReleaseInfo(): List { - assets ?: throw NoStackTraceException("获取新版本出错") + fun toAppReleaseInfo(): List { + val releaseAssets = assets.orEmpty() val version = tagName val abi = Build.SUPPORTED_ABIS.firstOrNull() ?: "" @@ -47,36 +48,38 @@ data class GithubRelease( else -> "" } - return assets + return releaseAssets .filter { it.isValid } .filter { asset -> abiSuffix.isEmpty() || asset.name.contains(abiSuffix, ignoreCase = true) } - .map { it.assetToAppReleaseInfo(isPreRelease, body, version) } + .map { it.assetToAppReleaseInfo(isPreRelease, body.orEmpty(), version) } } } @Keep -data class Asset( +data class GiteaAsset( @SerializedName("browser_download_url") - val apkUrl: String, + val apkUrl: String?, @SerializedName("content_type") - val contentType: String, + val contentType: String?, @SerializedName("created_at") - val createdAt: String, + val createdAt: String?, @SerializedName("download_count") val downloadCount: Int, val id: Int, val name: String, val state: String?, - val url: String + val url: String? ) { val isValid: Boolean get() = name.endsWith(".apk", ignoreCase = true) && + !apkUrl.isNullOrBlank() && + !createdAt.isNullOrBlank() && (state == null || state == "uploaded") 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 appVariant = if (preRelease) AppVariant.BETA_RELEASE else AppVariant.OFFICIAL @@ -85,8 +88,8 @@ data class Asset( createdAt = timestamp, note = note, name = name, - downloadUrl = apkUrl, - assetUrl = url, + downloadUrl = apkUrl!!, + assetUrl = url.orEmpty(), versionName = version ) } diff --git a/app/src/main/java/io/legado/app/help/update/AppUpdate.kt b/app/src/main/java/io/legado/app/help/update/AppUpdate.kt index 5e905aed2..629dfb990 100644 --- a/app/src/main/java/io/legado/app/help/update/AppUpdate.kt +++ b/app/src/main/java/io/legado/app/help/update/AppUpdate.kt @@ -5,8 +5,8 @@ import kotlinx.coroutines.CoroutineScope object AppUpdate { - val gitHubUpdate: AppUpdateInterface? by lazy { - AppUpdateGitHub + val giteaUpdate: AppUpdateInterface by lazy { + AppUpdateGitea } data class UpdateInfo( diff --git a/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt b/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt index da408b12b..0049b269e 100644 --- a/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt +++ b/app/src/main/java/io/legado/app/help/update/AppUpdateGitHub.kt @@ -1,5 +1,6 @@ package io.legado.app.help.update +import io.legado.app.R import io.legado.app.constant.AppConst import io.legado.app.exception.NoStackTraceException 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.fromJsonObject import kotlinx.coroutines.CoroutineScope +import splitties.init.appCtx -object AppUpdateGitHub : AppUpdate.AppUpdateInterface { +object AppUpdateGitea : AppUpdate.AppUpdateInterface { private const val RELEASES_API = "https://gitea.yamby.cn/api/v1/repos/yusheng/QieKan-3.0/releases" @@ -22,49 +24,31 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { "official_version" -> AppVariant.OFFICIAL "beta_release_version" -> AppVariant.BETA_RELEASE "all_version" -> AppVariant.ALL - else -> AppConst.appInfo.appVariant + else -> AppConst.appInfo.appVariant.takeIf { it != AppVariant.UNKNOWN } + ?: AppVariant.ALL } private suspend fun getLatestRelease(): List { - val url = if (checkVariant == AppVariant.OFFICIAL) - "$RELEASES_API/latest" - else - RELEASES_API - - val res = okHttpClient.newCallResponse { url(url) } - if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(${res.code})") + // Gitea 当前没有兼容 GitHub 的 /releases/latest 路由,统一读取列表后筛选。 + val res = okHttpClient.newCallResponse { url(RELEASES_API) } + if (!res.isSuccessful) throw NoStackTraceException("获取新版本出错(" + res.code + ")") val body = res.body.text() if (body.isBlank()) throw NoStackTraceException("获取新版本出错") - return when (checkVariant) { - AppVariant.BETA_RELEASE -> { - val releases = GSON.fromJsonArray(body) - .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") } - - releases.filter { it.isPreRelease } - .flatMap { it.gitReleaseToAppReleaseInfo() } - .sortedByDescending { it.createdAt } + val releases = GSON.fromJsonArray(body) + .getOrElse { throw NoStackTraceException("解析失败 " + it.localizedMessage) } + return releases + .filterNot { it.isDraft } + .filter { + when (checkVariant) { + AppVariant.OFFICIAL -> !it.isPreRelease + AppVariant.BETA_RELEASE -> it.isPreRelease + AppVariant.ALL, AppVariant.UNKNOWN -> true + } } - - AppVariant.OFFICIAL -> { - val release = GSON.fromJsonObject(body) - .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") } - - release.gitReleaseToAppReleaseInfo() - .sortedByDescending { it.createdAt } - } - - AppVariant.ALL -> { - val releases = GSON.fromJsonArray(body) - .getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") } - - releases.flatMap { it.gitReleaseToAppReleaseInfo() } - .sortedByDescending { it.createdAt } - } - - else -> emptyList() - } + .flatMap { it.toAppReleaseInfo() } + .sortedByDescending { it.createdAt } } suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? { @@ -73,8 +57,8 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { if (!res.isSuccessful) return null val body = res.body.text() - val release = GSON.fromJsonObject(body).getOrElse { return null } - val info = release.gitReleaseToAppReleaseInfo().firstOrNull() ?: return null + val release = GSON.fromJsonObject(body).getOrElse { return null } + val info = release.toAppReleaseInfo().firstOrNull() ?: return null return AppUpdate.UpdateInfo( tagName = info.versionName, @@ -112,7 +96,7 @@ object AppUpdateGitHub : AppUpdate.AppUpdateInterface { ) } - throw NoStackTraceException("已是最新版本") + throw NoStackTraceException(appCtx.getString(R.string.already_latest_version)) }.timeout(10000) } diff --git a/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt b/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt index c4a0feef6..a0efa7472 100644 --- a/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt +++ b/app/src/main/java/io/legado/app/ui/about/AboutScreen.kt @@ -88,6 +88,7 @@ private fun MaterialAboutScreen( val privacyPolicyTitle = stringResource(R.string.about_privacy_policy_title) val licenseTitle = stringResource(R.string.about_license_title) val disclaimerTitle = stringResource(R.string.about_disclaimer_title) + val giteaUrl = stringResource(R.string.gitea_url) AppScaffold( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection), @@ -145,13 +146,13 @@ private fun MaterialAboutScreen( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center ) { - FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) }) { + FilledTonalIconButton(onClick = { onIntent(AboutIntent.OpenUrl(giteaUrl)) }) { Icon( painter = painterResource(R.drawable.ic_web_outline), 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( painter = painterResource(R.drawable.ic_github), contentDescription = stringResource(R.string.back) @@ -172,7 +173,7 @@ private fun MaterialAboutScreen( SettingItemWithDivider { SettingItem( title = stringResource(R.string.contributors), - onClick = { onIntent(AboutIntent.OpenUrl("https://gitea.yamby.cn/yusheng/QieKan-3.0")) } + onClick = { onIntent(AboutIntent.OpenUrl(giteaUrl)) } ) } SettingItemWithDivider { diff --git a/app/src/main/java/io/legado/app/ui/about/AboutViewModel.kt b/app/src/main/java/io/legado/app/ui/about/AboutViewModel.kt index cbc674509..2f15550b8 100644 --- a/app/src/main/java/io/legado/app/ui/about/AboutViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/about/AboutViewModel.kt @@ -58,7 +58,7 @@ class AboutViewModel(application: Application) : BaseViewModel(application) { private fun checkUpdate() { _uiState.update { it.copy(dialog = AboutDialog.CheckingUpdate) } - AppUpdate.gitHubUpdate?.run { + AppUpdate.giteaUpdate.run { check(viewModelScope) .onSuccess { updateInfo -> _uiState.update { @@ -69,8 +69,15 @@ class AboutViewModel(application: Application) : BaseViewModel(application) { } }.onError { e -> _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( - AboutEffect.ShowToast("${context.getString(R.string.check_update)}\n${e.localizedMessage}") + AboutEffect.ShowToast(context.getString(R.string.check_update) + "\n" + message) ) }.onFinally { _uiState.update { it.copy(dialog = null) } diff --git a/app/src/main/java/io/legado/app/ui/about/MiuixAboutScreen.kt b/app/src/main/java/io/legado/app/ui/about/MiuixAboutScreen.kt index d41c3791a..9d40340a7 100644 --- a/app/src/main/java/io/legado/app/ui/about/MiuixAboutScreen.kt +++ b/app/src/main/java/io/legado/app/ui/about/MiuixAboutScreen.kt @@ -184,6 +184,7 @@ private fun AboutContent( val privacyPolicyTitle = stringResource(R.string.about_privacy_policy_title) val licenseTitle = stringResource(R.string.about_license_title) val disclaimerTitle = stringResource(R.string.about_disclaimer_title) + val giteaUrl = stringResource(R.string.gitea_url) val backdrop = rememberBlurBackdrop() var blurRadius by remember { mutableFloatStateOf(60f) } @@ -393,9 +394,9 @@ private fun AboutContent( ArrowPreference( title = stringResource(R.string.contributors), 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)) }, ) } diff --git a/app/src/main/java/io/legado/app/ui/audiobook/AudiobookContract.kt b/app/src/main/java/io/legado/app/ui/audiobook/AudiobookContract.kt new file mode 100644 index 000000000..179e99651 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/audiobook/AudiobookContract.kt @@ -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 = 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 +} diff --git a/app/src/main/java/io/legado/app/ui/audiobook/AudiobookScreen.kt b/app/src/main/java/io/legado/app/ui/audiobook/AudiobookScreen.kt new file mode 100644 index 000000000..79b849bb3 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/audiobook/AudiobookScreen.kt @@ -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, + 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 + ) + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/audiobook/AudiobookViewModel.kt b/app/src/main/java/io/legado/app/ui/audiobook/AudiobookViewModel.kt new file mode 100644 index 000000000..b1f8481b7 --- /dev/null +++ b/app/src/main/java/io/legado/app/ui/audiobook/AudiobookViewModel.kt @@ -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(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) + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfig.kt b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfig.kt index 896c1ed37..17b13b2fa 100644 --- a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfig.kt +++ b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfig.kt @@ -55,6 +55,16 @@ object BackupConfig { "both" ) + var backupFonts by prefDelegate( + PreferKey.backupFonts, + true + ) + + var backupLocalBooks by prefDelegate( + PreferKey.backupLocalBooks, + false + ) + var backupPath by prefDelegate( PreferKey.backupPath, null diff --git a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt index 9249a7788..321992512 100644 --- a/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/backupConfig/BackupConfigScreen.kt @@ -347,6 +347,20 @@ fun BackupConfigScreen( checked = BackupConfig.onlyLatestBackup, 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 } + ) } } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt index 72d464c5d..7bc43e3af 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainActivity.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainActivity.kt @@ -40,7 +40,7 @@ import io.legado.app.help.config.AppConfig import io.legado.app.help.config.LocalConfig import io.legado.app.help.coroutine.Coroutine 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.service.WebService import io.legado.app.ui.about.CrashLogsDialog @@ -346,7 +346,7 @@ open class MainActivity : BaseComposeActivity(), VariableDialog.Callback { if (!BuildConfig.DEBUG) { lifecycleScope.launch { try { - val info = AppUpdateGitHub.getReleaseByTag(BuildConfig.VERSION_NAME) + val info = AppUpdateGitea.getReleaseByTag(BuildConfig.VERSION_NAME) if (info != null) { val dialog = UpdateDialog(info, UpdateDialog.Mode.VIEW_LOG) dialog.setOnDismissListener { block.resume(null) } diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index ad6ba1fdc..0cc574758 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -26,6 +26,7 @@ import io.legado.app.ui.about.AboutEffect import io.legado.app.ui.about.AboutScreen import io.legado.app.ui.about.AboutViewModel 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.explore.ExploreShowIntent import io.legado.app.ui.book.explore.ExploreShowScreen @@ -99,6 +100,9 @@ fun MainActivity.mainEntryProvider( onNavigateToChat = { onNavigateToRoute(MainRouteAiChat) }, + onNavigateToAudiobooks = { + onNavigateToRoute(MainRouteAudiobooks) + }, onNavigateToSearch = { key -> onNavigateToRoute( MainRouteSearch( @@ -724,4 +728,8 @@ fun MainActivity.mainEntryProvider( onBack = { onNavigateBack() }, ) } + + entry { + AudiobookRouteScreen(onBackClick = { onNavigateBack() }) + } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt index c43400fa0..bd73d285a 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt @@ -36,6 +36,9 @@ data object MainRouteSettingsAi : MainRoute @Serializable data object MainRouteAiChat : MainRoute +@Serializable +data object MainRouteAudiobooks : MainRoute + @Serializable data class MainRouteSettingsAiProviderEdit( val providerId: String? = null diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt index 447ea3ef9..4a1db31d5 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt @@ -71,6 +71,7 @@ object MainNavigator { MainRouteImportLocal, MainRouteImportRemote, + MainRouteAudiobooks, is MainRouteCache, MainRouteBookCacheManage, is MainRouteReadBook -> { diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 3e3c71f05..63338f219 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -118,6 +118,7 @@ fun MainScreen( useRail: Boolean, onOpenSettings: () -> Unit, onNavigateToChat: () -> Unit, + onNavigateToAudiobooks: () -> Unit, onNavigateToSearch: (String?) -> Unit, onNavigateToRemoteImport: () -> Unit, onNavigateToLocalImport: () -> Unit, @@ -478,6 +479,7 @@ fun MainScreen( MainDestination.My -> MyScreen( onOpenSettings = onOpenSettings, onNavigateToChat = onNavigateToChat, + onNavigateToAudiobooks = onNavigateToAudiobooks, onNavigate = { event -> when (event) { PrefClickEvent.OpenBookCacheManage -> onNavigateToBookCacheManage() diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt index 11ca67a40..f0a432e22 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.FindReplace import androidx.compose.material.icons.filled.Folder 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.OpenInBrowser import androidx.compose.material.icons.filled.Sell @@ -69,6 +70,7 @@ fun MyScreen( viewModel: MyViewModel = koinViewModel(), onOpenSettings: () -> Unit, onNavigateToChat: () -> Unit, + onNavigateToAudiobooks: () -> Unit, onNavigate: (PrefClickEvent) -> Unit ) { @@ -172,6 +174,12 @@ fun MyScreen( SplicedColumnGroup( 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( title = stringResource(R.string.ai_chat), imageVector = Icons.Default.AutoAwesome, diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1ad4596ab..50e2b424b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -235,6 +235,7 @@ 粘贴源 源规则说明 检查更新 + 当前已是最新版本 扫描二维码 扫描本地图片 规则说明 @@ -876,7 +877,7 @@ 支付宝红包二维码 支付宝收款二维码 QQ 收款二维码 - gedoor、Invinciblelee 和 Xwite 等,详情请在 GitHub 中查看 + gedoor、Invinciblelee 和 Xwite 等,详情请在 Gitea 项目中查看 清除已下载书籍和字体缓存 默认封面 恢复忽略列表 @@ -1306,6 +1307,10 @@ 本地密码用来对备份的敏感信息加密和解密,如需在不同设备之间同步,本地密码需一致。 仅保留最新备份 本地备份仅保留最新备份文件 + 备份自定义字体 + 将阅读设置中使用的自定义字体一并备份,恢复时自动还原 + 备份本地书籍 + 将本地书籍文件一并备份,恢复时自动导入到书籍保存目录 WebDav 应用验证失败 加载字数 排除范围,选填书名或者书源 URL @@ -2245,4 +2250,28 @@ %1$s(导入) 日间 %1$d · 夜间 %2$d 当前使用 + 有声书 + 连接 Audiobookshelf 服务 + 服务器 + 媒体库 + 连接管理 + 服务器名称 + 服务器地址 + https://example.com/audiobookshelf + 用户名 + 密码 + 连接并登录 + 重新登录 + 刷新 + 修改连接 + 服务端版本:%1$s + HTTP 连接不会加密账号令牌,请仅在可信局域网中使用。 + 没有可访问的媒体库 + 请检查该账号在服务端的媒体库权限。 + 有声书媒体库 + 播客媒体库 + 移除这个服务器? + 本地连接配置和加密刷新令牌将被删除,服务端数据不会受到影响。 + 移除服务器 + 取消 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index 379c90d2a..c31b88834 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -186,6 +186,7 @@ 粘帖源 源規則説明 檢查更新 + 目前已是最新版本 掃描 QR Code 掃描本地圖片 規則説明 @@ -772,7 +773,7 @@ 支付寶紅包二維碼 支付寶收款二維碼 QQ收款二維碼 - gedoor,Invinciblelee,Xwite等,詳情請在github中查看 + gedoor,Invinciblelee,Xwite等,詳情請在 Gitea 專案中查看 清除已下載書籍和字體緩存 默認封面 恢復忽略列表 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 4df6844f5..84f930432 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -188,6 +188,7 @@ 貼上源 源規則說明 檢查更新 + 目前已是最新版本 掃描二維碼 掃描本機圖片 規則說明 @@ -780,7 +781,7 @@ 支付寶紅包二維碼 支付寶收款二維碼 QQ收款二維碼 - gedoor、Invinciblelee、Xwite等,詳情請在github中查看 + gedoor、Invinciblelee、Xwite等,詳情請在 Gitea 專案中查看 清除已下載書籍和字體快取 預設封面 復原忽略列表 diff --git a/app/src/main/res/values/non_translat.xml b/app/src/main/res/values/non_translat.xml index 2bc25e9c6..53e9c0b60 100644 --- a/app/src/main/res/values/non_translat.xml +++ b/app/src/main/res/values/non_translat.xml @@ -4,11 +4,11 @@ 开源阅读 gekunfei@live.com - https://github.com/gedoor/legado/graphs/contributors - https://gitea.yamby.cn/yusheng/QieKan-3.0 + https://gitea.yamby.cn/yusheng/QieKan-3.0 + https://gitea.yamby.cn/yusheng/QieKan-3.0 https://gedoor.github.io/ http://%1$s:%2$d - Gitea + Gitea 【%s】 🔒%s 🔓%s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 0f870890a..a1d187077 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -256,6 +256,7 @@ Paste source Source rules description Check for Updates + You are already using the latest version Scan QR code Scan local images Rules description @@ -901,7 +902,7 @@ AliPay red envelope QR code AliPay QR code QQ Collection QR code - gedoor,Invinciblelee,Xwite etc. Checking in github for details + gedoor,Invinciblelee,Xwite etc. See the Gitea project for details Clear the cache of the downloaded books and fonts Default Cover Bypass List @@ -1347,6 +1348,10 @@ 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. Keep Only the Latest Backup Local backup keeps only the latest backup file + Backup Custom Fonts + Include custom fonts used in reading settings in the backup, and restore them on the target device + Backup Local Books + Include the files of local books in the backup, and restore them to the book save folder on the target device Fail to authorize WebDAV application respondTime: %1$d ms Load word count @@ -2250,4 +2255,28 @@ %1$s (Imported) Light %1$d · Dark %2$d Currently selected + Audiobooks + Connect to an Audiobookshelf server + Server + Libraries + Connection + Server name + Server address + https://example.com/audiobookshelf + Username + Password + Connect and sign in + Sign in again + Refresh + Edit connection + Server version: %1$s + HTTP sends account tokens without transport encryption. Use it only on a trusted LAN. + No accessible libraries + Check this account\'s library permissions on the server. + Audiobook library + Podcast library + Remove this server? + The local connection and encrypted refresh token will be removed. Server data will not be changed. + Remove server + Cancel diff --git a/app/version.properties b/app/version.properties index 5167805d3..fc4b9b18e 100644 --- a/app/version.properties +++ b/app/version.properties @@ -1,5 +1,5 @@ VERSION_MAJOR=3 VERSION_MINOR=26 -VERSION_PATCH=14 +VERSION_PATCH=15 VERSION_SUFFIX=1 # 1 = Pre, 0 = Release diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 83cdfb3e8..9b19ff3d5 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -136,7 +136,7 @@ export default defineConfig({ ], }, socialLinks: [ - { icon: 'github', link: 'https://github.com/HapeLee/legado-with-MD3' }, + { icon: 'github', link: 'https://gitea.yamby.cn/yusheng/QieKan-3.0' }, ], footer: { message: '基于 Apache-2.0 许可发布', @@ -157,8 +157,8 @@ export default defineConfig({ text: '最后更新于', }, editLink: { - pattern: 'https://github.com/HapeLee/legado-with-MD3/edit/main/docs/:path', - text: '在 GitHub 上编辑此页面', + pattern: 'https://gitea.yamby.cn/yusheng/QieKan-3.0/_edit/main/docs/:path', + text: '在 Gitea 上编辑此页面', }, }, lastUpdated: true, diff --git a/docs/architecture/audiobook-integration.md b/docs/architecture/audiobook-integration.md new file mode 100644 index 000000000..abf772137 --- /dev/null +++ b/docs/architecture/audiobook-integration.md @@ -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 密码或令牌。 diff --git a/docs/dev/index.md b/docs/dev/index.md index db9c7d244..51ccb2b8b 100644 --- a/docs/dev/index.md +++ b/docs/dev/index.md @@ -57,5 +57,5 @@ ## 外部资源 -- [Legado GitHub](https://github.com/HapeLee/legado-with-MD3) — 项目源码和 Issue 跟踪 -- [legado-with-MD3 Wiki](https://github.com/HapeLee/legado-with-MD3/wiki) — 社区维护的文档 +- [QieKan-3.0 Gitea](https://gitea.yamby.cn/yusheng/QieKan-3.0) — 项目源码和 Issue 跟踪 +- [QieKan-3.0 Wiki](https://gitea.yamby.cn/yusheng/QieKan-3.0/wiki) — 社区维护的文档 diff --git a/docs/dev/js.md b/docs/dev/js.md index 23b00bbc0..24379e91a 100644 --- a/docs/dev/js.md +++ b/docs/dev/js.md @@ -22,7 +22,7 @@ Java 类和方法。本文档列出所有可用的内置变量、对象属性和 - `java` 变量已被 Legado 修改,调用 `java.*` 下的包请使用 `Packages.java.*` - 在源规则中使用 `@js`、``、{{}} 可调用 Legado 内置的类和方法 - 为安全起见,部分 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 类和方法可能不同 - `const` 声明的变量不支持块级作用域,循环中使用会出现值不变的问题,请改用 `var` ::: @@ -36,12 +36,12 @@ Java 类和方法。本文档列出所有可用的内置变量、对象属性和 | `java` | `Object` | 全局 | 扩展工具对象,提供网络请求、加解密、文件操作等方法 | | `baseUrl` | `String` | 全局 | 当前请求的 URL | | `result` | `Any` | 全局 | 上一步规则的执行结果 | -| `book` | `Book` | 全局 | [书籍对象](https://github.com/HapeLee/legado-with-MD3/blob/master/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) | -| `chapter` | `BookChapter` | 全局 | [章节对象](https://github.com/HapeLee/legado-with-MD3/blob/master/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) | -| `cookie` | `CookieStore` | 全局 | [Cookie 操作对象](https://github.com/HapeLee/legado-with-MD3/blob/master/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) | +| `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://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/data/entities/RssArticle.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://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://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/app/src/main/java/io/legado/app/help/http/CookieStore.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` | 全局 | 当前章节标题 | | `src` | `String` | 全局 | 请求返回的源码 | | `nextChapterUrl` | `String` | 全局 | 下一章节 URL | @@ -50,7 +50,7 @@ Java 类和方法。本文档列出所有可用的内置变量、对象属性和 `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 作用域限制 只能在订阅源 `shouldOverrideUrlLoading` 规则中使用。URL 跳转拦截规则不能执行耗时操作。 @@ -61,7 +61,7 @@ java.searchBook(bookName: String) // 调用 Legado 搜索 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` 规则中有效。 @@ -72,7 +72,7 @@ java.addBook(bookUrl: String) // 添加书架 | `getStrResponse(jsStr, sourceRegex)` | `StrResponse` | 返回文本类型的访问结果 | | `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 // 获取文本/文本列表 @@ -95,7 +95,7 @@ java.get(key) 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, 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 算法的函数,由 [hutool-crypto](https://www.hutool.cn/docs/#/crypto/概述) 实现(当前版本 5.8.22)。 diff --git a/docs/dev/rule.md b/docs/dev/rule.md index f7d281229..7c1a0199c 100644 --- a/docs/dev/rule.md +++ b/docs/dev/rule.md @@ -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` | | `src` | 图片链接 | diff --git a/docs/index.md b/docs/index.md index dcd034400..13f65cdec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,8 +13,8 @@ hero: text: 开发文档 link: /dev/ - theme: alt - text: GitHub - link: https://github.com/HapeLee/legado-with-MD3 + text: Gitea + link: https://gitea.yamby.cn/yusheng/QieKan-3.0 features: - icon: 📖 diff --git a/modules/audiobookshelf-client/build.gradle.kts b/modules/audiobookshelf-client/build.gradle.kts new file mode 100644 index 000000000..a2fcb2967 --- /dev/null +++ b/modules/audiobookshelf-client/build.gradle.kts @@ -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) +} diff --git a/modules/audiobookshelf-client/src/main/AndroidManifest.xml b/modules/audiobookshelf-client/src/main/AndroidManifest.xml new file mode 100644 index 000000000..cc947c567 --- /dev/null +++ b/modules/audiobookshelf-client/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfApi.kt b/modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfApi.kt new file mode 100644 index 000000000..7b9cfad25 --- /dev/null +++ b/modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfApi.kt @@ -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(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(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(request).user.toSession() + } + + fun getLibraries(baseUrl: String, accessToken: String): List { + val request = requestBuilder(baseUrl, "api/libraries") + .header(AUTHORIZATION, "Bearer $accessToken") + .get() + .build() + return execute(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 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(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 = 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 = emptyList() +) + +@Serializable +private data class LibraryDto( + val id: String, + val name: String, + val mediaType: String, + @SerialName("icon") val icon: String? = null +) diff --git a/modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfApiException.kt b/modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfApiException.kt new file mode 100644 index 000000000..7bf07719b --- /dev/null +++ b/modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfApiException.kt @@ -0,0 +1,6 @@ +package io.legado.audiobookshelf.client + +class AudiobookshelfApiException( + val statusCode: Int, + message: String +) : Exception(message) diff --git a/modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfModels.kt b/modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfModels.kt new file mode 100644 index 000000000..1469cef70 --- /dev/null +++ b/modules/audiobookshelf-client/src/main/java/io/legado/audiobookshelf/client/AudiobookshelfModels.kt @@ -0,0 +1,20 @@ +package io.legado.audiobookshelf.client + +data class AudiobookshelfServerStatus( + val version: String, + val initialized: Boolean, + val authMethods: List +) + +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? +) diff --git a/modules/audiobookshelf-client/src/test/java/io/legado/audiobookshelf/client/AudiobookshelfApiTest.kt b/modules/audiobookshelf-client/src/test/java/io/legado/audiobookshelf/client/AudiobookshelfApiTest.kt new file mode 100644 index 000000000..e49a3ad84 --- /dev/null +++ b/modules/audiobookshelf-client/src/test/java/io/legado/audiobookshelf/client/AudiobookshelfApiTest.kt @@ -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") + } + } +} diff --git a/settings.gradle b/settings.gradle index f866ee642..a06aaf2cd 100644 --- a/settings.gradle +++ b/settings.gradle @@ -41,6 +41,7 @@ rootProject.name = 'legado' include ':app' include ':modules:book' +include ':modules:audiobookshelf-client' include ':modules:rhino' include ':baselineprofile' project(':baselineprofile').projectDir = file('baselineProfile')