feat: release QieKan 3.26.15
This commit is contained in:
@@ -19,3 +19,6 @@ node_modules/
|
||||
/.vscode/settings.json
|
||||
/.codex/skills
|
||||
/.backup/
|
||||
/.audiobookshelf/
|
||||
/.gradle-codex/
|
||||
/.tools/
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
# Legado with MD3
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/HapeLee/legado-with-MD3">简体中文</a> |
|
||||
<a href="https://github.com/HapeLee/legado-with-MD3/blob/main/English.md">English</a>
|
||||
<a href="https://gitea.yamby.cn/yusheng/QieKan-3.0">简体中文</a> |
|
||||
<a href="https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/English.md">English</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Legado with MD3
|
||||
|
||||
<p align="left">
|
||||
<a href="https://github.com/HapeLee/legado-with-MD3">简体中文</a> |
|
||||
<a href="https://github.com/HapeLee/legado-with-MD3/blob/main/English.md">English</a>
|
||||
<a href="https://gitea.yamby.cn/yusheng/QieKan-3.0">简体中文</a> |
|
||||
<a href="https://gitea.yamby.cn/yusheng/QieKan-3.0/src/branch/main/English.md">English</a>
|
||||
</p>
|
||||
|
||||
## 📖 介绍
|
||||
@@ -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)
|
||||
下载。
|
||||
|
||||
---
|
||||
|
||||
@@ -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"))))
|
||||
|
||||
@@ -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<GithubRelease>(body)
|
||||
val releaseList = Gson().fromJsonArray<GiteaRelease>(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,14 +40,15 @@ class UpdateTest {
|
||||
val body = okHttpClient.newCall(Request.Builder().url(lastReleaseUrl).build()).execute()
|
||||
.body!!.string()
|
||||
|
||||
val releaseList = Gson().fromJsonObject<GithubRelease>(body)
|
||||
val releaseList = Gson().fromJsonArray<GiteaRelease>(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() })
|
||||
}
|
||||
|
||||
@@ -22576,17 +22576,17 @@ PLio/legado/app/help/storage/BackupConfig$$ExternalSyntheticLambda0;->invoke()Lj
|
||||
PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V
|
||||
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;-><clinit>()V
|
||||
HSPLio/legado/app/help/update/AppUpdateGitHub;-><init>()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;-><init>(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;-><init>(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;-><clinit>()V
|
||||
HSPLio/legado/app/help/update/AppUpdateGitea;-><init>()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;-><init>(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;-><init>(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;-><clinit>()V
|
||||
|
||||
@@ -22576,17 +22576,17 @@ PLio/legado/app/help/storage/BackupConfig$$ExternalSyntheticLambda0;->invoke()Lj
|
||||
PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V
|
||||
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;-><clinit>()V
|
||||
HSPLio/legado/app/help/update/AppUpdateGitHub;-><init>()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;-><init>(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;-><init>(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;-><clinit>()V
|
||||
HSPLio/legado/app/help/update/AppUpdateGitea;-><init>()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;-><init>(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;-><init>(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;-><clinit>()V
|
||||
|
||||
@@ -22576,17 +22576,17 @@ PLio/legado/app/help/storage/BackupConfig$$ExternalSyntheticLambda0;->invoke()Lj
|
||||
PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V
|
||||
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;-><clinit>()V
|
||||
HSPLio/legado/app/help/update/AppUpdateGitHub;-><init>()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;-><init>(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;-><init>(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;-><clinit>()V
|
||||
HSPLio/legado/app/help/update/AppUpdateGitea;-><init>()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;-><init>(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;-><init>(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;-><clinit>()V
|
||||
|
||||
@@ -22576,17 +22576,17 @@ PLio/legado/app/help/storage/BackupConfig$$ExternalSyntheticLambda0;->invoke()Lj
|
||||
PLio/legado/app/help/storage/BackupConfig$ignoreConfig_delegate$lambda$0$$inlined$fromJsonObject$1;-><init>()V
|
||||
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;-><clinit>()V
|
||||
HSPLio/legado/app/help/update/AppUpdateGitHub;-><init>()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;-><init>(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;-><init>(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;-><clinit>()V
|
||||
HSPLio/legado/app/help/update/AppUpdateGitea;-><init>()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;-><init>(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;-><init>(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;-><clinit>()V
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package io.legado.app.data.audiobook
|
||||
|
||||
import io.legado.audiobookshelf.client.AudiobookshelfApi
|
||||
import io.legado.audiobookshelf.client.AudiobookshelfApiException
|
||||
import io.legado.app.data.dao.ServerDao
|
||||
import io.legado.app.data.entities.Server
|
||||
import io.legado.app.domain.gateway.AudiobookAuthenticationRequiredException
|
||||
import io.legado.app.domain.gateway.AudiobookGateway
|
||||
import io.legado.app.domain.model.AudiobookHomeSnapshot
|
||||
import io.legado.app.domain.model.AudiobookLibrarySummary
|
||||
import io.legado.app.domain.model.AudiobookServerProfile
|
||||
import io.legado.app.utils.GSON
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class AudiobookRepository(
|
||||
private val serverDao: ServerDao,
|
||||
private val api: AudiobookshelfApi,
|
||||
private val tokenStore: AudiobookTokenStore
|
||||
) : AudiobookGateway {
|
||||
|
||||
private val accessTokens = ConcurrentHashMap<Long, String>()
|
||||
private val refreshMutex = Mutex()
|
||||
|
||||
override suspend fun getSavedProfile(): AudiobookServerProfile? = withContext(Dispatchers.IO) {
|
||||
serverDao.all.firstNotNullOfOrNull { server -> server.toAudiobookProfileOrNull() }
|
||||
}
|
||||
|
||||
override suspend fun connect(
|
||||
existingProfileId: Long?,
|
||||
name: String,
|
||||
baseUrl: String,
|
||||
username: String,
|
||||
password: String
|
||||
): AudiobookHomeSnapshot = withContext(Dispatchers.IO) {
|
||||
require(username.isNotBlank()) { "请输入用户名" }
|
||||
require(password.isNotBlank()) { "请输入密码" }
|
||||
|
||||
val normalizedBaseUrl = AudiobookshelfApi.normalizeBaseUrl(baseUrl)
|
||||
val status = api.getStatus(normalizedBaseUrl)
|
||||
require(status.initialized) { "Audiobookshelf 服务尚未完成初始化" }
|
||||
|
||||
val auth = api.login(
|
||||
baseUrl = normalizedBaseUrl,
|
||||
username = username.trim(),
|
||||
password = password
|
||||
)
|
||||
val libraries = api.getLibraries(normalizedBaseUrl, auth.accessToken)
|
||||
val existingServer = existingProfileId
|
||||
?.let(serverDao::get)
|
||||
?.takeIf { it.type == Server.TYPE.AUDIOBOOKSHELF }
|
||||
val profileId = existingServer?.id ?: System.currentTimeMillis()
|
||||
val profileName = name.trim().ifEmpty { "Audiobookshelf" }
|
||||
val savedUsername = auth.username.ifBlank { username.trim() }
|
||||
val server = Server(
|
||||
id = profileId,
|
||||
name = profileName,
|
||||
type = Server.TYPE.AUDIOBOOKSHELF,
|
||||
config = GSON.toJson(
|
||||
Server.AudiobookshelfConfig(
|
||||
url = normalizedBaseUrl,
|
||||
username = savedUsername
|
||||
)
|
||||
),
|
||||
sortNumber = existingServer?.sortNumber ?: nextSortNumber()
|
||||
)
|
||||
|
||||
tokenStore.saveRefreshToken(profileId, auth.refreshToken)
|
||||
serverDao.insert(server)
|
||||
accessTokens[profileId] = auth.accessToken
|
||||
|
||||
buildSnapshot(server.toAudiobookProfile(), status.version, libraries)
|
||||
}
|
||||
|
||||
override suspend fun restore(profileId: Long): AudiobookHomeSnapshot {
|
||||
return refresh(profileId)
|
||||
}
|
||||
|
||||
override suspend fun refresh(profileId: Long): AudiobookHomeSnapshot = withContext(Dispatchers.IO) {
|
||||
val profile = requireProfile(profileId)
|
||||
val status = api.getStatus(profile.baseUrl)
|
||||
var accessToken = accessTokens[profileId]
|
||||
?: refreshAccessToken(profileId, profile.baseUrl)
|
||||
|
||||
val libraries = try {
|
||||
api.getLibraries(profile.baseUrl, accessToken)
|
||||
} catch (error: AudiobookshelfApiException) {
|
||||
if (error.statusCode != 401) throw error
|
||||
accessToken = refreshAccessToken(
|
||||
profileId = profileId,
|
||||
baseUrl = profile.baseUrl,
|
||||
rejectedAccessToken = accessToken
|
||||
)
|
||||
api.getLibraries(profile.baseUrl, accessToken)
|
||||
}
|
||||
|
||||
buildSnapshot(profile, status.version, libraries)
|
||||
}
|
||||
|
||||
override suspend fun forget(profileId: Long) = withContext(Dispatchers.IO) {
|
||||
serverDao.get(profileId)
|
||||
?.takeIf { it.type == Server.TYPE.AUDIOBOOKSHELF }
|
||||
?.let(serverDao::delete)
|
||||
accessTokens.remove(profileId)
|
||||
tokenStore.delete(profileId)
|
||||
}
|
||||
|
||||
private suspend fun refreshAccessToken(
|
||||
profileId: Long,
|
||||
baseUrl: String,
|
||||
rejectedAccessToken: String? = null
|
||||
): String = refreshMutex.withLock {
|
||||
val current = accessTokens[profileId]
|
||||
if (current != null && current != rejectedAccessToken) return@withLock current
|
||||
|
||||
val refreshToken = tokenStore.readRefreshToken(profileId)
|
||||
?: throw AudiobookAuthenticationRequiredException()
|
||||
try {
|
||||
val auth = api.refresh(baseUrl, refreshToken)
|
||||
tokenStore.saveRefreshToken(profileId, auth.refreshToken)
|
||||
accessTokens[profileId] = auth.accessToken
|
||||
auth.accessToken
|
||||
} catch (error: AudiobookshelfApiException) {
|
||||
if (error.statusCode == 401) {
|
||||
tokenStore.delete(profileId)
|
||||
accessTokens.remove(profileId)
|
||||
throw AudiobookAuthenticationRequiredException()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireProfile(profileId: Long): AudiobookServerProfile {
|
||||
return serverDao.get(profileId)?.toAudiobookProfileOrNull()
|
||||
?: throw IllegalArgumentException("未找到有声书服务器配置")
|
||||
}
|
||||
|
||||
private fun nextSortNumber(): Int {
|
||||
return (serverDao.all.maxOfOrNull(Server::sortNumber) ?: -1) + 1
|
||||
}
|
||||
|
||||
private fun Server.toAudiobookProfileOrNull(): AudiobookServerProfile? {
|
||||
if (type != Server.TYPE.AUDIOBOOKSHELF) return null
|
||||
return getAudiobookshelfConfig()?.let { config ->
|
||||
AudiobookServerProfile(
|
||||
id = id,
|
||||
name = name,
|
||||
baseUrl = config.url,
|
||||
username = config.username
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Server.toAudiobookProfile(): AudiobookServerProfile {
|
||||
return requireNotNull(toAudiobookProfileOrNull())
|
||||
}
|
||||
|
||||
private fun buildSnapshot(
|
||||
profile: AudiobookServerProfile,
|
||||
serverVersion: String,
|
||||
libraries: List<io.legado.audiobookshelf.client.AudiobookshelfLibrary>
|
||||
): AudiobookHomeSnapshot {
|
||||
return AudiobookHomeSnapshot(
|
||||
profile = profile,
|
||||
serverVersion = serverVersion,
|
||||
libraries = libraries.map { library ->
|
||||
AudiobookLibrarySummary(
|
||||
id = library.id,
|
||||
name = library.name,
|
||||
mediaType = library.mediaType,
|
||||
icon = library.icon
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package io.legado.app.data.audiobook
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class AudiobookTokenStore(context: Context) {
|
||||
|
||||
private val tokenDirectory = File(context.noBackupFilesDir, TOKEN_DIRECTORY)
|
||||
private val keyAlias = "${context.packageName}.audiobookshelf.refresh"
|
||||
|
||||
fun saveRefreshToken(profileId: Long, refreshToken: String) {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
|
||||
init(Cipher.ENCRYPT_MODE, getOrCreateSecretKey())
|
||||
}
|
||||
val encrypted = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
|
||||
tokenDirectory.mkdirs()
|
||||
tokenFile(profileId).writeText(
|
||||
buildString {
|
||||
append(Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
|
||||
append('\n')
|
||||
append(Base64.encodeToString(encrypted, Base64.NO_WRAP))
|
||||
},
|
||||
Charsets.UTF_8
|
||||
)
|
||||
}
|
||||
|
||||
fun readRefreshToken(profileId: Long): String? {
|
||||
val file = tokenFile(profileId)
|
||||
if (!file.isFile) return null
|
||||
return runCatching {
|
||||
val lines = file.readLines(Charsets.UTF_8)
|
||||
require(lines.size == 2)
|
||||
val iv = Base64.decode(lines[0], Base64.NO_WRAP)
|
||||
val encrypted = Base64.decode(lines[1], Base64.NO_WRAP)
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION).apply {
|
||||
init(
|
||||
Cipher.DECRYPT_MODE,
|
||||
getOrCreateSecretKey(),
|
||||
GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)
|
||||
)
|
||||
}
|
||||
cipher.doFinal(encrypted).toString(Charsets.UTF_8)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
fun delete(profileId: Long) {
|
||||
tokenFile(profileId).delete()
|
||||
}
|
||||
|
||||
private fun tokenFile(profileId: Long): File {
|
||||
return File(tokenDirectory, "$profileId.token")
|
||||
}
|
||||
|
||||
private fun getOrCreateSecretKey(): SecretKey {
|
||||
val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
||||
(keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it }
|
||||
|
||||
val generator = KeyGenerator.getInstance(
|
||||
KeyProperties.KEY_ALGORITHM_AES,
|
||||
ANDROID_KEY_STORE
|
||||
)
|
||||
generator.init(
|
||||
KeyGenParameterSpec.Builder(
|
||||
keyAlias,
|
||||
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||
)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||
.setRandomizedEncryptionRequired(true)
|
||||
.build()
|
||||
)
|
||||
return generator.generateKey()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TOKEN_DIRECTORY = "audiobookshelf"
|
||||
const val ANDROID_KEY_STORE = "AndroidKeyStore"
|
||||
const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||
const val GCM_TAG_LENGTH_BITS = 128
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@ data class Server(
|
||||
) : Parcelable {
|
||||
|
||||
enum class TYPE {
|
||||
WEBDAV
|
||||
WEBDAV,
|
||||
AUDIOBOOKSHELF
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
@@ -47,6 +48,14 @@ data class Server(
|
||||
return if (type == TYPE.WEBDAV) GSON.fromJsonObject<WebDavConfig>(config).getOrNull() else null
|
||||
}
|
||||
|
||||
fun getAudiobookshelfConfig(): AudiobookshelfConfig? {
|
||||
return if (type == TYPE.AUDIOBOOKSHELF) {
|
||||
GSON.fromJsonObject<AudiobookshelfConfig>(config).getOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class WebDavConfig(
|
||||
var url: String,
|
||||
@@ -54,4 +63,10 @@ data class Server(
|
||||
var password: String
|
||||
) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class AudiobookshelfConfig(
|
||||
var url: String,
|
||||
var username: String
|
||||
) : Parcelable
|
||||
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import io.legado.app.model.remote.RemoteBook
|
||||
import io.legado.app.model.remote.RemoteBookWebDav
|
||||
import io.legado.app.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<List<Server>> {
|
||||
return appDb.serverDao.observeAll()
|
||||
return appDb.serverDao.observeAll().map { servers ->
|
||||
servers.filter { it.type == Server.TYPE.WEBDAV }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getServer(id: Long): Server? {
|
||||
|
||||
@@ -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<AudiobookGateway> { AudiobookRepository(get(), get(), get()) }
|
||||
|
||||
single<UploadRepository> { DirectLinkUploadRepository() }
|
||||
single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
|
||||
single<AiProfileGateway> { AiProfileRepository(get()) }
|
||||
@@ -315,6 +324,7 @@ val appModule = module {
|
||||
viewModelOf(::BackupConfigViewModel)
|
||||
viewModelOf(::AiConfigViewModel)
|
||||
viewModelOf(::AiChatViewModel)
|
||||
viewModelOf(::AudiobookViewModel)
|
||||
viewModel { (providerId: String?) ->
|
||||
AiProviderEditViewModel(
|
||||
initialProviderId = providerId,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package io.legado.app.domain.gateway
|
||||
|
||||
import io.legado.app.domain.model.AudiobookHomeSnapshot
|
||||
import io.legado.app.domain.model.AudiobookServerProfile
|
||||
|
||||
interface AudiobookGateway {
|
||||
|
||||
suspend fun getSavedProfile(): AudiobookServerProfile?
|
||||
|
||||
suspend fun connect(
|
||||
existingProfileId: Long?,
|
||||
name: String,
|
||||
baseUrl: String,
|
||||
username: String,
|
||||
password: String
|
||||
): AudiobookHomeSnapshot
|
||||
|
||||
suspend fun restore(profileId: Long): AudiobookHomeSnapshot
|
||||
|
||||
suspend fun refresh(profileId: Long): AudiobookHomeSnapshot
|
||||
|
||||
suspend fun forget(profileId: Long)
|
||||
}
|
||||
|
||||
class AudiobookAuthenticationRequiredException(
|
||||
message: String = "登录已失效,请重新输入密码"
|
||||
) : Exception(message)
|
||||
@@ -0,0 +1,21 @@
|
||||
package io.legado.app.domain.model
|
||||
|
||||
data class AudiobookServerProfile(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val baseUrl: String,
|
||||
val username: String
|
||||
)
|
||||
|
||||
data class AudiobookLibrarySummary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val mediaType: String,
|
||||
val icon: String?
|
||||
)
|
||||
|
||||
data class AudiobookHomeSnapshot(
|
||||
val profile: AudiobookServerProfile,
|
||||
val serverVersion: String,
|
||||
val libraries: List<AudiobookLibrarySummary>
|
||||
)
|
||||
@@ -198,6 +198,7 @@ fun Book.getLocalUri(): Uri {
|
||||
val newBook = oldBook.copy(bookUrl = newBookUrl)
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>()
|
||||
if (AppConfig.backupFonts) {
|
||||
backupFontsTo(File(backupPath))?.let { extraBackupPaths.add(it) }
|
||||
}
|
||||
if (AppConfig.backupLocalBooks) {
|
||||
backupLocalBooksTo(File(backupPath))?.let { extraBackupPaths.add(it) }
|
||||
}
|
||||
writeListToJson(appDb.bookDao.all, "bookshelf.json", backupPath)
|
||||
writeListToJson(appDb.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<String>()
|
||||
ReadBookConfig.configList.forEach { config ->
|
||||
config.highlightRules.forEach { rule ->
|
||||
rule.fontPath?.takeIf { it.isNotBlank() }?.let(fontPathSet::add)
|
||||
}
|
||||
listOf(config.textFont, config.titleFont, config.headerFont, config.footerFont)
|
||||
.filter { it.isNotBlank() }
|
||||
.forEach(fontPathSet::add)
|
||||
}
|
||||
listOf(
|
||||
ReadBookConfig.shareConfig.textFont,
|
||||
ReadBookConfig.shareConfig.titleFont,
|
||||
ReadBookConfig.shareConfig.headerFont,
|
||||
ReadBookConfig.shareConfig.footerFont
|
||||
).filter { it.isNotBlank() }.forEach(fontPathSet::add)
|
||||
ReadBookConfig.shareConfig.highlightRules.forEach { rule ->
|
||||
rule.fontPath?.takeIf { it.isNotBlank() }?.let(fontPathSet::add)
|
||||
}
|
||||
appDb.highlightRuleDao.getAll().forEach { rule ->
|
||||
rule.fontPath?.takeIf { it.isNotBlank() }?.let(fontPathSet::add)
|
||||
}
|
||||
ThemeConfig.appFontPath?.takeIf { it.isNotBlank() }?.let(fontPathSet::add)
|
||||
|
||||
val fontsDir = backupDir.getFile(fontsDirName)
|
||||
//原始字体路径到备份文件名的映射, 恢复时用于重写字体路径
|
||||
val fontPathMap = hashMapOf<String, String>()
|
||||
var count = 0
|
||||
var failed = 0
|
||||
fontPathSet.forEach { fontPath ->
|
||||
val sourceUri = fontPath.toUri()
|
||||
val fileName = kotlin.runCatching {
|
||||
FileDoc.fromUri(sourceUri, false).name
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
val target = fontsDir.getFile(uniqueBackupFileName(fileName, fontPath))
|
||||
val copied = kotlin.runCatching {
|
||||
sourceUri.inputStream(appCtx).getOrThrow().use { input ->
|
||||
fontsDir.mkdirs()
|
||||
FileOutputStream(target, false).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
failed++
|
||||
AppLog.put("备份字体 $fileName 出错\n${it.localizedMessage}", it)
|
||||
}.isSuccess
|
||||
if (copied) {
|
||||
count++
|
||||
fontPathMap[fontPath] = target.name
|
||||
}
|
||||
}
|
||||
if (fontPathMap.isEmpty()) return@runCatching null
|
||||
fontsDir.mkdirs()
|
||||
fontsDir.getFile(fontMapFileName).writeText(GSON.toJson(fontPathMap))
|
||||
if (failed > 0) {
|
||||
AppLog.put("备份字体完成,成功 $count 个,跳过 $failed 个")
|
||||
}
|
||||
LogUtils.d(TAG, "备份字体 $count 个,跳过 $failed 个")
|
||||
fontsDir.absolutePath
|
||||
}.getOrElse {
|
||||
AppLog.put("备份字体出错\n${it.localizedMessage}", it)
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* 备份本地书籍文件, 返回打包目录路径
|
||||
*/
|
||||
private fun backupLocalBooksTo(backupDir: File): String? = kotlin.runCatching {
|
||||
val books = appDb.bookDao.all.filter { it.isLocal }
|
||||
if (books.isEmpty()) return@runCatching null
|
||||
val localBooksDir = backupDir.getFile(localBooksDirName)
|
||||
val bookUrlMap = linkedMapOf<String, String>()
|
||||
var count = 0
|
||||
var failed = 0
|
||||
books.forEach { book ->
|
||||
val originalBookUrl = book.bookUrl
|
||||
val originalName = book.originName.ifBlank { book.name }
|
||||
val targetName = uniqueBackupFileName(originalName, originalBookUrl)
|
||||
val copied = kotlin.runCatching {
|
||||
//读取实际书籍内容。对于从压缩包导入的书籍,这里备份解压后的书籍文件,
|
||||
//避免依赖源设备上的压缩包路径,恢复后可直接重绑定。
|
||||
LocalBook.getBookInputStream(book).use { input ->
|
||||
localBooksDir.mkdirs()
|
||||
FileOutputStream(localBooksDir.getFile(targetName), false).use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
count++
|
||||
}
|
||||
}.onFailure {
|
||||
failed++
|
||||
AppLog.put("备份本地书籍 ${book.name} 出错\n${it.localizedMessage}", it)
|
||||
}.isSuccess
|
||||
if (copied) {
|
||||
// getBookInputStream 可能触发旧设备路径重绑,使用重绑后的 URL 写入映射。
|
||||
bookUrlMap[book.bookUrl] = targetName
|
||||
}
|
||||
}
|
||||
if (bookUrlMap.isEmpty()) return@runCatching null
|
||||
localBooksDir.getFile(localBooksMapFileName).writeText(GSON.toJson(bookUrlMap))
|
||||
if (failed > 0) {
|
||||
AppLog.put("备份本地书籍完成,成功 $count 本,跳过 $failed 本")
|
||||
}
|
||||
LogUtils.d(TAG, "备份本地书籍 $count 本,跳过 $failed 本")
|
||||
localBooksDir.absolutePath
|
||||
}.getOrElse {
|
||||
AppLog.put("备份本地书籍出错\n${it.localizedMessage}", it)
|
||||
null
|
||||
}
|
||||
|
||||
private fun uniqueBackupFileName(originalName: String, key: String): String {
|
||||
val source = File(originalName)
|
||||
val baseName = source.nameWithoutExtension.ifBlank { "resource" }
|
||||
val extension = source.extension.takeIf { it.isNotBlank() }?.let { ".${it}" }.orEmpty()
|
||||
return "${baseName}_${MD5Utils.md5Encode16(key)}$extension".normalizeFileName()
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
@Suppress("SameParameterValue")
|
||||
private fun copyBackup(context: Context, uri: Uri, fileName: String) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String, Any?> = 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<HighlightRuleRepository.BackupData>(
|
||||
file.readText()
|
||||
).getOrNull() ?: return
|
||||
appDb.highlightRuleDao.replaceAll(backupData.rules)
|
||||
appCtx.defaultSharedPreferences.edit {
|
||||
putBoolean(PreferKey.highlightRuleDialog, backupData.dialogEnabled)
|
||||
putBoolean(PreferKey.highlightRuleBookTitle, backupData.bookTitleEnabled)
|
||||
putBoolean(PreferKey.highlightRuleBracketNote, backupData.bracketNoteEnabled)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复自定义字体文件, 并按映射重写排版配置中的字体路径。
|
||||
* 阅读配置和主题配置分别遵守恢复忽略项,避免只恢复资源却修改了被忽略的配置。
|
||||
*/
|
||||
private fun restoreFonts(
|
||||
fontsDir: File,
|
||||
restoreReadFonts: Boolean,
|
||||
restoreThemeFont: Boolean,
|
||||
backedAppFontPath: String?,
|
||||
) {
|
||||
if (!fontsDir.exists() || (!restoreReadFonts && !restoreThemeFont)) return
|
||||
val targetFontDir = appCtx.externalFiles.getFile("font")
|
||||
targetFontDir.mkdirs()
|
||||
fontsDir.listFiles()?.forEach { fontFile ->
|
||||
if (fontFile.isFile && fontFile.name != Backup.fontMapFileName) {
|
||||
// 用户已选择恢复字体,备份内容应覆盖目标设备同名旧字体。
|
||||
fontFile.copyTo(targetFontDir.getFile(fontFile.name), overwrite = true)
|
||||
}
|
||||
}
|
||||
//原始字体路径到备份文件名的映射
|
||||
val fontMap = File(fontsDir, Backup.fontMapFileName)
|
||||
.takeIf { it.exists() }
|
||||
?.runCatching {
|
||||
GSON.fromJsonObject<Map<String, String>>(readText()).getOrNull()
|
||||
}?.getOrNull().orEmpty()
|
||||
|
||||
fun rewriteFont(fontPath: String?): String? {
|
||||
if (fontPath.isNullOrBlank()) return fontPath
|
||||
val fileName = fontMap[fontPath] ?: File(fontPath).name
|
||||
if (fileName.isBlank()) return fontPath
|
||||
val newFont = targetFontDir.getFile(fileName)
|
||||
return if (newFont.exists()) newFont.absolutePath else fontPath
|
||||
}
|
||||
|
||||
var changed = false
|
||||
fun rewriteConfig(config: ReadBookConfig.Config) {
|
||||
rewriteFont(config.textFont)?.let {
|
||||
if (it != config.textFont) { config.textFont = it; changed = true }
|
||||
}
|
||||
rewriteFont(config.titleFont)?.let {
|
||||
if (it != config.titleFont) { config.titleFont = it; changed = true }
|
||||
}
|
||||
rewriteFont(config.headerFont)?.let {
|
||||
if (it != config.headerFont) { config.headerFont = it; changed = true }
|
||||
}
|
||||
rewriteFont(config.footerFont)?.let {
|
||||
if (it != config.footerFont) { config.footerFont = it; changed = true }
|
||||
}
|
||||
config.highlightRules.forEach { rule ->
|
||||
val newFontPath = rewriteFont(rule.fontPath)
|
||||
if (newFontPath != rule.fontPath) { rule.fontPath = newFontPath; changed = true }
|
||||
}
|
||||
}
|
||||
if (restoreReadFonts) {
|
||||
ReadBookConfig.configList.forEach(::rewriteConfig)
|
||||
rewriteConfig(ReadBookConfig.shareConfig)
|
||||
// 高亮规则当前单独存储在 Room 中,不只存在于 ReadBookConfig JSON。
|
||||
appDb.highlightRuleDao.getAll().forEach { rule ->
|
||||
val newFontPath = rewriteFont(rule.fontPath)
|
||||
if (newFontPath != rule.fontPath) {
|
||||
appDb.highlightRuleDao.update(rule.copy(fontPath = newFontPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (restoreThemeFont) {
|
||||
// 直接使用备份 XML 中的值,避免 DataStore observer 尚未刷新时读取到旧 appFontPath。
|
||||
val sourceAppFontPath = backedAppFontPath ?: ThemeConfig.appFontPath
|
||||
val newAppFontPath = rewriteFont(sourceAppFontPath)
|
||||
if (newAppFontPath != ThemeConfig.appFontPath) {
|
||||
ThemeConfig.appFontPath = newAppFontPath
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
ReadBookConfig.save()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复本地书籍文件到书籍保存目录,并立即重绑书架及阅读记录中的 bookUrl。
|
||||
* 新版备份使用 localBooks.json 映射;没有映射的旧备份仍按原文件名兼容恢复。
|
||||
*/
|
||||
private suspend fun restoreLocalBooks(localBooksDir: File) {
|
||||
if (!localBooksDir.exists()) return
|
||||
val defaultBookTreeUri = OtherConfig.defaultBookTreeUri
|
||||
if (defaultBookTreeUri.isNullOrBlank()) {
|
||||
appCtx.toastOnUi(R.string.no_books_dir)
|
||||
return
|
||||
}
|
||||
|
||||
val bookMap = File(localBooksDir, Backup.localBooksMapFileName)
|
||||
.takeIf { it.exists() }
|
||||
?.runCatching {
|
||||
GSON.fromJsonObject<Map<String, String>>(readText()).getOrNull()
|
||||
}?.getOrNull().orEmpty()
|
||||
val entries = if (bookMap.isNotEmpty()) {
|
||||
bookMap.entries.map { it.key to it.value }
|
||||
} else {
|
||||
localBooksDir.listFiles()
|
||||
?.filter { it.isFile && it.name != Backup.localBooksMapFileName }
|
||||
?.map { null to it.name }
|
||||
.orEmpty()
|
||||
}
|
||||
if (entries.isEmpty()) return
|
||||
|
||||
var restored = 0
|
||||
entries.forEach { (oldBookUrl, fileName) ->
|
||||
val bookFile = File(localBooksDir, fileName)
|
||||
if (!bookFile.isFile) return@forEach
|
||||
kotlin.runCatching {
|
||||
val restoredUri = FileInputStream(bookFile).use { input ->
|
||||
// 使用备份目录中的唯一文件名,避免目标目录中存在同名旧书时串书。
|
||||
LocalBook.saveBookFile(input, fileName)
|
||||
}
|
||||
val newBookUrl = FileDoc.fromUri(restoredUri, false).toString()
|
||||
if (!oldBookUrl.isNullOrBlank() && oldBookUrl != newBookUrl) {
|
||||
rebindRestoredBook(oldBookUrl, newBookUrl)
|
||||
}
|
||||
restored++
|
||||
}.onFailure {
|
||||
AppLog.put("恢复本地书籍 ${bookFile.name} 出错\n${it.localizedMessage}", it)
|
||||
}
|
||||
}
|
||||
LogUtils.d(TAG, "恢复本地书籍 $restored 本")
|
||||
}
|
||||
|
||||
private fun rebindRestoredBook(oldBookUrl: String, newBookUrl: String) {
|
||||
val oldBook = appDb.bookDao.getBook(oldBookUrl) ?: return
|
||||
val newBook = oldBook.copy(
|
||||
bookUrl = newBookUrl,
|
||||
coverUrl = LocalBook.getCoverPath(oldBook.copy(bookUrl = newBookUrl))
|
||||
)
|
||||
appDb.runInTransaction {
|
||||
appDb.bookDao.replace(oldBook, newBook)
|
||||
BookHelp.updateCacheFolder(oldBook, newBook)
|
||||
appDb.readRecordDao.replaceBookUrl(oldBookUrl, newBookUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun applyConfigMap(map: Map<String, Any?>, aes: BackupAES) {
|
||||
val finalMap = mutableMapOf<String, Any>()
|
||||
appCtx.defaultSharedPreferences.edit {
|
||||
|
||||
@@ -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<Asset>?,
|
||||
val body: String,
|
||||
data class GiteaRelease(
|
||||
val assets: List<GiteaAsset>?,
|
||||
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<AppReleaseInfo> {
|
||||
assets ?: throw NoStackTraceException("获取新版本出错")
|
||||
fun toAppReleaseInfo(): List<AppReleaseInfo> {
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,59 +24,41 @@ 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<AppReleaseInfo> {
|
||||
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<GithubRelease>(body)
|
||||
.getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
|
||||
|
||||
releases.filter { it.isPreRelease }
|
||||
.flatMap { it.gitReleaseToAppReleaseInfo() }
|
||||
val releases = GSON.fromJsonArray<GiteaRelease>(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
|
||||
}
|
||||
}
|
||||
.flatMap { it.toAppReleaseInfo() }
|
||||
.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
AppVariant.OFFICIAL -> {
|
||||
val release = GSON.fromJsonObject<GithubRelease>(body)
|
||||
.getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
|
||||
|
||||
release.gitReleaseToAppReleaseInfo()
|
||||
.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
AppVariant.ALL -> {
|
||||
val releases = GSON.fromJsonArray<GithubRelease>(body)
|
||||
.getOrElse { throw NoStackTraceException("解析失败 ${it.localizedMessage}") }
|
||||
|
||||
releases.flatMap { it.gitReleaseToAppReleaseInfo() }
|
||||
.sortedByDescending { it.createdAt }
|
||||
}
|
||||
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getReleaseByTag(tag: String): AppUpdate.UpdateInfo? {
|
||||
val url = "$RELEASES_API/tags/$tag"
|
||||
val res = okHttpClient.newCallResponse { url(url) }
|
||||
if (!res.isSuccessful) return null
|
||||
|
||||
val body = res.body.text()
|
||||
val release = GSON.fromJsonObject<GithubRelease>(body).getOrElse { return null }
|
||||
val info = release.gitReleaseToAppReleaseInfo().firstOrNull() ?: return null
|
||||
val release = GSON.fromJsonObject<GiteaRelease>(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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.check_update)}\n${e.localizedMessage}")
|
||||
AboutEffect.ShowToast(context.getString(R.string.already_latest_version))
|
||||
)
|
||||
return@onError
|
||||
}
|
||||
_effects.tryEmit(
|
||||
AboutEffect.ShowToast(context.getString(R.string.check_update) + "\n" + message)
|
||||
)
|
||||
}.onFinally {
|
||||
_uiState.update { it.copy(dialog = null) }
|
||||
|
||||
@@ -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)) },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package io.legado.app.ui.audiobook
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Stable
|
||||
data class AudiobookUiState(
|
||||
val isLoading: Boolean = false,
|
||||
val isConnected: Boolean = false,
|
||||
val profileId: Long? = null,
|
||||
val serverName: String = "Audiobookshelf",
|
||||
val baseUrl: String = "",
|
||||
val username: String = "",
|
||||
val password: String = "",
|
||||
val serverVersion: String = "",
|
||||
val libraries: ImmutableList<AudiobookLibraryItemUi> = persistentListOf(),
|
||||
val dialog: AudiobookDialog? = null
|
||||
) {
|
||||
val canConnect: Boolean
|
||||
get() = baseUrl.isNotBlank() && username.isNotBlank() && password.isNotBlank()
|
||||
}
|
||||
|
||||
@Stable
|
||||
data class AudiobookLibraryItemUi(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val mediaType: String,
|
||||
val icon: String?
|
||||
)
|
||||
|
||||
sealed interface AudiobookIntent {
|
||||
data class ServerNameChanged(val value: String) : AudiobookIntent
|
||||
data class BaseUrlChanged(val value: String) : AudiobookIntent
|
||||
data class UsernameChanged(val value: String) : AudiobookIntent
|
||||
data class PasswordChanged(val value: String) : AudiobookIntent
|
||||
data object Connect : AudiobookIntent
|
||||
data object Refresh : AudiobookIntent
|
||||
data object EditConnection : AudiobookIntent
|
||||
data object RequestForget : AudiobookIntent
|
||||
data object ConfirmForget : AudiobookIntent
|
||||
data object DismissDialog : AudiobookIntent
|
||||
}
|
||||
|
||||
sealed interface AudiobookEffect {
|
||||
data class ShowMessage(val message: String) : AudiobookEffect
|
||||
}
|
||||
|
||||
sealed interface AudiobookDialog {
|
||||
data object ConfirmForget : AudiobookDialog
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package io.legado.app.ui.audiobook
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.DeleteOutline
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Headphones
|
||||
import androidx.compose.material.icons.filled.LibraryMusic
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material.icons.filled.Visibility
|
||||
import androidx.compose.material.icons.filled.VisibilityOff
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import io.legado.app.R
|
||||
import io.legado.app.ui.theme.LegadoTheme
|
||||
import io.legado.app.ui.theme.adaptiveContentPadding
|
||||
import io.legado.app.ui.widget.components.AppScaffold
|
||||
import io.legado.app.ui.widget.components.AppTextField
|
||||
import io.legado.app.ui.widget.components.SettingItemWithDivider
|
||||
import io.legado.app.ui.widget.components.SplicedColumnGroup
|
||||
import io.legado.app.ui.widget.components.alert.AppAlertDialog
|
||||
import io.legado.app.ui.widget.components.button.PrimaryButton
|
||||
import io.legado.app.ui.widget.components.button.SecondaryButton
|
||||
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
|
||||
import io.legado.app.ui.widget.components.settingItem.SettingItem
|
||||
import io.legado.app.ui.widget.components.text.AppText
|
||||
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
|
||||
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
|
||||
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
|
||||
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import org.koin.androidx.compose.koinViewModel
|
||||
|
||||
@Composable
|
||||
fun AudiobookRouteScreen(
|
||||
onBackClick: () -> Unit,
|
||||
viewModel: AudiobookViewModel = koinViewModel()
|
||||
) {
|
||||
AudiobookScreen(
|
||||
state = viewModel.uiState.collectAsStateWithLifecycle().value,
|
||||
effects = viewModel.effects,
|
||||
onIntent = viewModel::onIntent,
|
||||
onBackClick = onBackClick
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AudiobookScreen(
|
||||
state: AudiobookUiState,
|
||||
effects: Flow<AudiobookEffect>,
|
||||
onIntent: (AudiobookIntent) -> Unit,
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
effects.collectLatest { effect ->
|
||||
when (effect) {
|
||||
is AudiobookEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppScaffold(
|
||||
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
topBar = {
|
||||
GlassMediumFlexibleTopAppBar(
|
||||
title = stringResource(R.string.audiobookshelf_title),
|
||||
navigationIcon = { TopBarNavigationButton(onClick = onBackClick) },
|
||||
actions = {
|
||||
if (state.isConnected) {
|
||||
TopBarActionButton(
|
||||
onClick = { onIntent(AudiobookIntent.Refresh) },
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = stringResource(R.string.audiobookshelf_refresh)
|
||||
)
|
||||
}
|
||||
},
|
||||
scrollBehavior = scrollBehavior
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = adaptiveContentPadding(
|
||||
top = paddingValues.calculateTopPadding(),
|
||||
bottom = 48.dp
|
||||
)
|
||||
) {
|
||||
item {
|
||||
if (state.isConnected) {
|
||||
ConnectedContent(state = state, onIntent = onIntent)
|
||||
} else {
|
||||
ConnectionForm(state = state, onIntent = onIntent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.TopCenter)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppAlertDialog(
|
||||
show = state.dialog == AudiobookDialog.ConfirmForget,
|
||||
onDismissRequest = { onIntent(AudiobookIntent.DismissDialog) },
|
||||
title = stringResource(R.string.audiobookshelf_forget_title),
|
||||
text = stringResource(R.string.audiobookshelf_forget_message),
|
||||
confirmText = stringResource(R.string.audiobookshelf_forget_confirm),
|
||||
onConfirm = { onIntent(AudiobookIntent.ConfirmForget) },
|
||||
dismissText = stringResource(R.string.audiobookshelf_cancel),
|
||||
onDismiss = { onIntent(AudiobookIntent.DismissDialog) }
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectionForm(
|
||||
state: AudiobookUiState,
|
||||
onIntent: (AudiobookIntent) -> Unit
|
||||
) {
|
||||
var passwordVisible by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
SplicedColumnGroup(title = stringResource(R.string.audiobookshelf_server_section)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerLow)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
AppTextField(
|
||||
value = state.serverName,
|
||||
onValueChange = { onIntent(AudiobookIntent.ServerNameChanged(it)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isLoading,
|
||||
label = stringResource(R.string.audiobookshelf_server_name),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
)
|
||||
AppTextField(
|
||||
value = state.baseUrl,
|
||||
onValueChange = { onIntent(AudiobookIntent.BaseUrlChanged(it)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isLoading,
|
||||
label = stringResource(R.string.audiobookshelf_server_url),
|
||||
placeholder = { AppText(stringResource(R.string.audiobookshelf_server_url_example)) },
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Uri,
|
||||
imeAction = ImeAction.Next
|
||||
)
|
||||
)
|
||||
if (state.baseUrl.trim().startsWith("http://", ignoreCase = true)) {
|
||||
AppText(
|
||||
text = stringResource(R.string.audiobookshelf_http_warning),
|
||||
style = LegadoTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.tertiary
|
||||
)
|
||||
}
|
||||
AppTextField(
|
||||
value = state.username,
|
||||
onValueChange = { onIntent(AudiobookIntent.UsernameChanged(it)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isLoading,
|
||||
label = stringResource(R.string.audiobookshelf_username),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next)
|
||||
)
|
||||
AppTextField(
|
||||
value = state.password,
|
||||
onValueChange = { onIntent(AudiobookIntent.PasswordChanged(it)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isLoading,
|
||||
label = stringResource(R.string.audiobookshelf_password),
|
||||
singleLine = true,
|
||||
visualTransformation = if (passwordVisible) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
PasswordVisualTransformation()
|
||||
},
|
||||
trailingIcon = {
|
||||
IconButton(onClick = { passwordVisible = !passwordVisible }) {
|
||||
Icon(
|
||||
imageVector = if (passwordVisible) {
|
||||
Icons.Default.VisibilityOff
|
||||
} else {
|
||||
Icons.Default.Visibility
|
||||
},
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Password,
|
||||
imeAction = ImeAction.Done
|
||||
)
|
||||
)
|
||||
PrimaryButton(
|
||||
onClick = { onIntent(AudiobookIntent.Connect) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = state.canConnect && !state.isLoading,
|
||||
text = stringResource(
|
||||
if (state.profileId == null) {
|
||||
R.string.audiobookshelf_connect
|
||||
} else {
|
||||
R.string.audiobookshelf_reconnect
|
||||
}
|
||||
)
|
||||
)
|
||||
if (state.profileId != null) {
|
||||
SecondaryButton(
|
||||
onClick = { onIntent(AudiobookIntent.RequestForget) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = !state.isLoading,
|
||||
text = stringResource(R.string.audiobookshelf_forget_confirm)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConnectedContent(
|
||||
state: AudiobookUiState,
|
||||
onIntent: (AudiobookIntent) -> Unit
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
SplicedColumnGroup(title = stringResource(R.string.audiobookshelf_server_section)) {
|
||||
SettingItemWithDivider {
|
||||
SettingItem(
|
||||
title = state.serverName,
|
||||
description = state.baseUrl,
|
||||
option = stringResource(
|
||||
R.string.audiobookshelf_server_version,
|
||||
state.serverVersion.ifBlank { "-" }
|
||||
),
|
||||
imageVector = Icons.Default.Headphones
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SplicedColumnGroup(title = stringResource(R.string.audiobookshelf_actions_section)) {
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.audiobookshelf_refresh),
|
||||
imageVector = Icons.Default.Refresh,
|
||||
onClick = { onIntent(AudiobookIntent.Refresh) }
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.audiobookshelf_edit_connection),
|
||||
imageVector = Icons.Default.Edit,
|
||||
onClick = { onIntent(AudiobookIntent.EditConnection) }
|
||||
)
|
||||
ClickableSettingItem(
|
||||
title = stringResource(R.string.audiobookshelf_forget_confirm),
|
||||
imageVector = Icons.Default.DeleteOutline,
|
||||
onClick = { onIntent(AudiobookIntent.RequestForget) }
|
||||
)
|
||||
}
|
||||
|
||||
SplicedColumnGroup(title = stringResource(R.string.audiobookshelf_library_section)) {
|
||||
if (state.libraries.isEmpty()) {
|
||||
SettingItemWithDivider {
|
||||
SettingItem(
|
||||
title = stringResource(R.string.audiobookshelf_no_libraries),
|
||||
description = stringResource(R.string.audiobookshelf_no_libraries_desc),
|
||||
imageVector = Icons.Default.LibraryMusic
|
||||
)
|
||||
}
|
||||
} else {
|
||||
state.libraries.forEach { library ->
|
||||
SettingItemWithDivider {
|
||||
SettingItem(
|
||||
title = library.name,
|
||||
description = when (library.mediaType) {
|
||||
"book" -> stringResource(R.string.audiobookshelf_library_books)
|
||||
"podcast" -> stringResource(R.string.audiobookshelf_library_podcasts)
|
||||
else -> library.mediaType
|
||||
},
|
||||
imageVector = Icons.Default.LibraryMusic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package io.legado.app.ui.audiobook
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import io.legado.app.domain.gateway.AudiobookGateway
|
||||
import io.legado.app.domain.model.AudiobookHomeSnapshot
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AudiobookViewModel(
|
||||
private val audiobookGateway: AudiobookGateway
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(AudiobookUiState())
|
||||
val uiState = _uiState.asStateFlow()
|
||||
|
||||
private val _effects = MutableSharedFlow<AudiobookEffect>(extraBufferCapacity = 16)
|
||||
val effects = _effects.asSharedFlow()
|
||||
|
||||
init {
|
||||
loadSavedProfile()
|
||||
}
|
||||
|
||||
fun onIntent(intent: AudiobookIntent) {
|
||||
when (intent) {
|
||||
is AudiobookIntent.ServerNameChanged -> updateForm { copy(serverName = intent.value) }
|
||||
is AudiobookIntent.BaseUrlChanged -> updateForm { copy(baseUrl = intent.value) }
|
||||
is AudiobookIntent.UsernameChanged -> updateForm { copy(username = intent.value) }
|
||||
is AudiobookIntent.PasswordChanged -> updateForm { copy(password = intent.value) }
|
||||
AudiobookIntent.Connect -> connect()
|
||||
AudiobookIntent.Refresh -> refresh()
|
||||
AudiobookIntent.EditConnection -> {
|
||||
_uiState.update { it.copy(isConnected = false, password = "") }
|
||||
}
|
||||
AudiobookIntent.RequestForget -> {
|
||||
_uiState.update { it.copy(dialog = AudiobookDialog.ConfirmForget) }
|
||||
}
|
||||
AudiobookIntent.ConfirmForget -> forget()
|
||||
AudiobookIntent.DismissDialog -> {
|
||||
_uiState.update { it.copy(dialog = null) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSavedProfile() {
|
||||
viewModelScope.launch {
|
||||
runCatching { audiobookGateway.getSavedProfile() }
|
||||
.onFailure(::showError)
|
||||
.getOrNull()
|
||||
?.let { profile ->
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = true,
|
||||
profileId = profile.id,
|
||||
serverName = profile.name,
|
||||
baseUrl = profile.baseUrl,
|
||||
username = profile.username,
|
||||
password = ""
|
||||
)
|
||||
}
|
||||
runCatching { audiobookGateway.restore(profile.id) }
|
||||
.onSuccess(::showSnapshot)
|
||||
.onFailure { error ->
|
||||
_uiState.update { it.copy(isLoading = false, isConnected = false) }
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun connect() {
|
||||
val state = _uiState.value
|
||||
if (!state.canConnect || state.isLoading) return
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
audiobookGateway.connect(
|
||||
existingProfileId = state.profileId,
|
||||
name = state.serverName,
|
||||
baseUrl = state.baseUrl,
|
||||
username = state.username,
|
||||
password = state.password
|
||||
)
|
||||
}.onSuccess(::showSnapshot)
|
||||
.onFailure { error ->
|
||||
_uiState.update { it.copy(isLoading = false, isConnected = false) }
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh() {
|
||||
val profileId = _uiState.value.profileId ?: return
|
||||
if (_uiState.value.isLoading) return
|
||||
_uiState.update { it.copy(isLoading = true) }
|
||||
viewModelScope.launch {
|
||||
runCatching { audiobookGateway.refresh(profileId) }
|
||||
.onSuccess(::showSnapshot)
|
||||
.onFailure { error ->
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun forget() {
|
||||
val profileId = _uiState.value.profileId ?: return
|
||||
_uiState.update { it.copy(isLoading = true, dialog = null) }
|
||||
viewModelScope.launch {
|
||||
runCatching { audiobookGateway.forget(profileId) }
|
||||
.onSuccess { _uiState.value = AudiobookUiState() }
|
||||
.onFailure { error ->
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
showError(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showSnapshot(snapshot: AudiobookHomeSnapshot) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
isConnected = true,
|
||||
profileId = snapshot.profile.id,
|
||||
serverName = snapshot.profile.name,
|
||||
baseUrl = snapshot.profile.baseUrl,
|
||||
username = snapshot.profile.username,
|
||||
password = "",
|
||||
serverVersion = snapshot.serverVersion,
|
||||
libraries = snapshot.libraries.map { library ->
|
||||
AudiobookLibraryItemUi(
|
||||
id = library.id,
|
||||
name = library.name,
|
||||
mediaType = library.mediaType,
|
||||
icon = library.icon
|
||||
)
|
||||
}.toImmutableList(),
|
||||
dialog = null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showError(error: Throwable) {
|
||||
_effects.tryEmit(
|
||||
AudiobookEffect.ShowMessage(
|
||||
error.message?.takeIf { it.isNotBlank() } ?: "有声书服务连接失败"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateForm(transform: AudiobookUiState.() -> AudiobookUiState) {
|
||||
if (_uiState.value.isLoading) return
|
||||
_uiState.update(transform)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,16 @@ object BackupConfig {
|
||||
"both"
|
||||
)
|
||||
|
||||
var backupFonts by prefDelegate(
|
||||
PreferKey.backupFonts,
|
||||
true
|
||||
)
|
||||
|
||||
var backupLocalBooks by prefDelegate(
|
||||
PreferKey.backupLocalBooks,
|
||||
false
|
||||
)
|
||||
|
||||
var backupPath by prefDelegate<String?>(
|
||||
PreferKey.backupPath,
|
||||
null
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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<MainRouteAudiobooks> {
|
||||
AudiobookRouteScreen(onBackClick = { onNavigateBack() })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -71,6 +71,7 @@ object MainNavigator {
|
||||
|
||||
MainRouteImportLocal,
|
||||
MainRouteImportRemote,
|
||||
MainRouteAudiobooks,
|
||||
is MainRouteCache,
|
||||
MainRouteBookCacheManage,
|
||||
is MainRouteReadBook -> {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -235,6 +235,7 @@
|
||||
<string name="paste_source">粘贴源</string>
|
||||
<string name="source_rule_s">源规则说明</string>
|
||||
<string name="check_update">检查更新</string>
|
||||
<string name="already_latest_version">当前已是最新版本</string>
|
||||
<string name="camera_scan">扫描二维码</string>
|
||||
<string name="scan_image">扫描本地图片</string>
|
||||
<string name="rule_summary">规则说明</string>
|
||||
@@ -876,7 +877,7 @@
|
||||
<string name="alipay_red_envelope_qr_code">支付宝红包二维码</string>
|
||||
<string name="alipay_payment_qr_code">支付宝收款二维码</string>
|
||||
<string name="qq_collection_qr_code">QQ 收款二维码</string>
|
||||
<string name="contributors_summary">gedoor、Invinciblelee 和 Xwite 等,详情请在 GitHub 中查看</string>
|
||||
<string name="contributors_summary">gedoor、Invinciblelee 和 Xwite 等,详情请在 Gitea 项目中查看</string>
|
||||
<string name="clear_cache_summary">清除已下载书籍和字体缓存</string>
|
||||
<string name="default_cover">默认封面</string>
|
||||
<string name="restore_ignore">恢复忽略列表</string>
|
||||
@@ -1306,6 +1307,10 @@
|
||||
<string name="set_local_password_summary">本地密码用来对备份的敏感信息加密和解密,如需在不同设备之间同步,本地密码需一致。</string>
|
||||
<string name="only_latest_backup_t">仅保留最新备份</string>
|
||||
<string name="only_latest_backup_s">本地备份仅保留最新备份文件</string>
|
||||
<string name="backup_fonts_t">备份自定义字体</string>
|
||||
<string name="backup_fonts_s">将阅读设置中使用的自定义字体一并备份,恢复时自动还原</string>
|
||||
<string name="backup_local_books_t">备份本地书籍</string>
|
||||
<string name="backup_local_books_s">将本地书籍文件一并备份,恢复时自动导入到书籍保存目录</string>
|
||||
<string name="webdav_application_authorization_error">WebDav 应用验证失败</string>
|
||||
<string name="load_word_count">加载字数</string>
|
||||
<string name="replace_exclude_scope">排除范围,选填书名或者书源 URL</string>
|
||||
@@ -2245,4 +2250,28 @@
|
||||
<string name="cover_album_imported_name">%1$s(导入)</string>
|
||||
<string name="cover_album_day_night_count">日间 %1$d · 夜间 %2$d</string>
|
||||
<string name="cover_album_selected">当前使用</string>
|
||||
<string name="audiobookshelf_title">有声书</string>
|
||||
<string name="audiobookshelf_entry_description">连接 Audiobookshelf 服务</string>
|
||||
<string name="audiobookshelf_server_section">服务器</string>
|
||||
<string name="audiobookshelf_library_section">媒体库</string>
|
||||
<string name="audiobookshelf_actions_section">连接管理</string>
|
||||
<string name="audiobookshelf_server_name">服务器名称</string>
|
||||
<string name="audiobookshelf_server_url">服务器地址</string>
|
||||
<string name="audiobookshelf_server_url_example">https://example.com/audiobookshelf</string>
|
||||
<string name="audiobookshelf_username">用户名</string>
|
||||
<string name="audiobookshelf_password">密码</string>
|
||||
<string name="audiobookshelf_connect">连接并登录</string>
|
||||
<string name="audiobookshelf_reconnect">重新登录</string>
|
||||
<string name="audiobookshelf_refresh">刷新</string>
|
||||
<string name="audiobookshelf_edit_connection">修改连接</string>
|
||||
<string name="audiobookshelf_server_version">服务端版本:%1$s</string>
|
||||
<string name="audiobookshelf_http_warning">HTTP 连接不会加密账号令牌,请仅在可信局域网中使用。</string>
|
||||
<string name="audiobookshelf_no_libraries">没有可访问的媒体库</string>
|
||||
<string name="audiobookshelf_no_libraries_desc">请检查该账号在服务端的媒体库权限。</string>
|
||||
<string name="audiobookshelf_library_books">有声书媒体库</string>
|
||||
<string name="audiobookshelf_library_podcasts">播客媒体库</string>
|
||||
<string name="audiobookshelf_forget_title">移除这个服务器?</string>
|
||||
<string name="audiobookshelf_forget_message">本地连接配置和加密刷新令牌将被删除,服务端数据不会受到影响。</string>
|
||||
<string name="audiobookshelf_forget_confirm">移除服务器</string>
|
||||
<string name="audiobookshelf_cancel">取消</string>
|
||||
</resources>
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
<string name="paste_source">粘帖源</string>
|
||||
<string name="source_rule_s">源規則説明</string>
|
||||
<string name="check_update">檢查更新</string>
|
||||
<string name="already_latest_version">目前已是最新版本</string>
|
||||
<string name="camera_scan">掃描 QR Code</string>
|
||||
<string name="scan_image">掃描本地圖片</string>
|
||||
<string name="rule_summary">規則説明</string>
|
||||
@@ -772,7 +773,7 @@
|
||||
<string name="alipay_red_envelope_qr_code">支付寶紅包二維碼</string>
|
||||
<string name="alipay_payment_qr_code">支付寶收款二維碼</string>
|
||||
<string name="qq_collection_qr_code">QQ收款二維碼</string>
|
||||
<string name="contributors_summary">gedoor,Invinciblelee,Xwite等,詳情請在github中查看</string>
|
||||
<string name="contributors_summary">gedoor,Invinciblelee,Xwite等,詳情請在 Gitea 專案中查看</string>
|
||||
<string name="clear_cache_summary">清除已下載書籍和字體緩存</string>
|
||||
<string name="default_cover">默認封面</string>
|
||||
<string name="restore_ignore">恢復忽略列表</string>
|
||||
|
||||
@@ -188,6 +188,7 @@
|
||||
<string name="paste_source">貼上源</string>
|
||||
<string name="source_rule_s">源規則說明</string>
|
||||
<string name="check_update">檢查更新</string>
|
||||
<string name="already_latest_version">目前已是最新版本</string>
|
||||
<string name="camera_scan">掃描二維碼</string>
|
||||
<string name="scan_image">掃描本機圖片</string>
|
||||
<string name="rule_summary">規則說明</string>
|
||||
@@ -780,7 +781,7 @@
|
||||
<string name="alipay_red_envelope_qr_code">支付寶紅包二維碼</string>
|
||||
<string name="alipay_payment_qr_code">支付寶收款二維碼</string>
|
||||
<string name="qq_collection_qr_code">QQ收款二維碼</string>
|
||||
<string name="contributors_summary">gedoor、Invinciblelee、Xwite等,詳情請在github中查看</string>
|
||||
<string name="contributors_summary">gedoor、Invinciblelee、Xwite等,詳情請在 Gitea 專案中查看</string>
|
||||
<string name="clear_cache_summary">清除已下載書籍和字體快取</string>
|
||||
<string name="default_cover">預設封面</string>
|
||||
<string name="restore_ignore">復原忽略列表</string>
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<string name="legado_gzh" translatable="false">开源阅读</string>
|
||||
<string name="email" translatable="false">gekunfei@live.com</string>
|
||||
|
||||
<string name="contributors_url" translatable="false">https://github.com/gedoor/legado/graphs/contributors</string>
|
||||
<string name="github_url" translatable="false">https://gitea.yamby.cn/yusheng/QieKan-3.0</string>
|
||||
<string name="contributors_url" translatable="false">https://gitea.yamby.cn/yusheng/QieKan-3.0</string>
|
||||
<string name="gitea_url" translatable="false">https://gitea.yamby.cn/yusheng/QieKan-3.0</string>
|
||||
<string name="legado_url" translatable="false">https://gedoor.github.io/</string>
|
||||
<string name="http_ip" translatable="false">http://%1$s:%2$d</string>
|
||||
<string name="git_hub" translatable="false">Gitea</string>
|
||||
<string name="gitea" translatable="false">Gitea</string>
|
||||
<string name="diy_edit_source_group_title" translatable="false">【%s】</string>
|
||||
<string name="vip_title" translatable="false">🔒%s</string>
|
||||
<string name="payed_title" translatable="false">🔓%s</string>
|
||||
|
||||
@@ -256,6 +256,7 @@
|
||||
<string name="paste_source">Paste source</string>
|
||||
<string name="source_rule_s">Source rules description</string>
|
||||
<string name="check_update">Check for Updates</string>
|
||||
<string name="already_latest_version">You are already using the latest version</string>
|
||||
<string name="camera_scan">Scan QR code</string>
|
||||
<string name="scan_image">Scan local images</string>
|
||||
<string name="rule_summary">Rules description</string>
|
||||
@@ -901,7 +902,7 @@
|
||||
<string name="alipay_red_envelope_qr_code">AliPay red envelope QR code</string>
|
||||
<string name="alipay_payment_qr_code">AliPay QR code</string>
|
||||
<string name="qq_collection_qr_code">QQ Collection QR code</string>
|
||||
<string name="contributors_summary">gedoor,Invinciblelee,Xwite etc. Checking in github for details</string>
|
||||
<string name="contributors_summary">gedoor,Invinciblelee,Xwite etc. See the Gitea project for details</string>
|
||||
<string name="clear_cache_summary">Clear the cache of the downloaded books and fonts</string>
|
||||
<string name="default_cover">Default Cover</string>
|
||||
<string name="restore_ignore">Bypass List</string>
|
||||
@@ -1347,6 +1348,10 @@
|
||||
<string name="set_local_password_summary">The local password is used to encrypt and decrypt the sensitive information of the backup, if you need to synchronize between different devices, the local password should be the same.</string>
|
||||
<string name="only_latest_backup_t">Keep Only the Latest Backup</string>
|
||||
<string name="only_latest_backup_s">Local backup keeps only the latest backup file</string>
|
||||
<string name="backup_fonts_t">Backup Custom Fonts</string>
|
||||
<string name="backup_fonts_s">Include custom fonts used in reading settings in the backup, and restore them on the target device</string>
|
||||
<string name="backup_local_books_t">Backup Local Books</string>
|
||||
<string name="backup_local_books_s">Include the files of local books in the backup, and restore them to the book save folder on the target device</string>
|
||||
<string name="webdav_application_authorization_error">Fail to authorize WebDAV application</string>
|
||||
<string name="respondTime">respondTime: %1$d ms</string>
|
||||
<string name="load_word_count">Load word count</string>
|
||||
@@ -2250,4 +2255,28 @@
|
||||
<string name="cover_album_imported_name">%1$s (Imported)</string>
|
||||
<string name="cover_album_day_night_count">Light %1$d · Dark %2$d</string>
|
||||
<string name="cover_album_selected">Currently selected</string>
|
||||
<string name="audiobookshelf_title">Audiobooks</string>
|
||||
<string name="audiobookshelf_entry_description">Connect to an Audiobookshelf server</string>
|
||||
<string name="audiobookshelf_server_section">Server</string>
|
||||
<string name="audiobookshelf_library_section">Libraries</string>
|
||||
<string name="audiobookshelf_actions_section">Connection</string>
|
||||
<string name="audiobookshelf_server_name">Server name</string>
|
||||
<string name="audiobookshelf_server_url">Server address</string>
|
||||
<string name="audiobookshelf_server_url_example">https://example.com/audiobookshelf</string>
|
||||
<string name="audiobookshelf_username">Username</string>
|
||||
<string name="audiobookshelf_password">Password</string>
|
||||
<string name="audiobookshelf_connect">Connect and sign in</string>
|
||||
<string name="audiobookshelf_reconnect">Sign in again</string>
|
||||
<string name="audiobookshelf_refresh">Refresh</string>
|
||||
<string name="audiobookshelf_edit_connection">Edit connection</string>
|
||||
<string name="audiobookshelf_server_version">Server version: %1$s</string>
|
||||
<string name="audiobookshelf_http_warning">HTTP sends account tokens without transport encryption. Use it only on a trusted LAN.</string>
|
||||
<string name="audiobookshelf_no_libraries">No accessible libraries</string>
|
||||
<string name="audiobookshelf_no_libraries_desc">Check this account\'s library permissions on the server.</string>
|
||||
<string name="audiobookshelf_library_books">Audiobook library</string>
|
||||
<string name="audiobookshelf_library_podcasts">Podcast library</string>
|
||||
<string name="audiobookshelf_forget_title">Remove this server?</string>
|
||||
<string name="audiobookshelf_forget_message">The local connection and encrypted refresh token will be removed. Server data will not be changed.</string>
|
||||
<string name="audiobookshelf_forget_confirm">Remove server</string>
|
||||
<string name="audiobookshelf_cancel">Cancel</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
VERSION_MAJOR=3
|
||||
VERSION_MINOR=26
|
||||
VERSION_PATCH=14
|
||||
VERSION_PATCH=15
|
||||
VERSION_SUFFIX=1
|
||||
# 1 = Pre, 0 = Release
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# 且看有声书融合架构
|
||||
|
||||
## 决策
|
||||
|
||||
且看将 Audiobookshelf 作为独立的伴随服务端,通过原生 Android 客户端模块接入。
|
||||
Node.js、Nuxt、Sequelize、服务端 SQLite、FFmpeg 和媒体扫描器不打包进 APK。
|
||||
|
||||
当前工作区中的 `.audiobookshelf` 仅用于协议核对和服务端 Fork 开发。正式维护时应将它
|
||||
保留为独立仓库或 Git submodule,并使用可识别的 Fork 版本号,例如
|
||||
`2.34.0-qiekan.1`。
|
||||
|
||||
## 模块边界
|
||||
|
||||
- `:modules:audiobookshelf-client`
|
||||
- Audiobookshelf REST 请求、DTO 和协议模型。
|
||||
- 不依赖且看的 Room、Compose、主题或阅读领域。
|
||||
- `app/domain/gateway/AudiobookGateway.kt` 与 `app/domain/model/AudiobookModels.kt`
|
||||
- 服务器、媒体库、书籍、播放会话和进度的领域接口及模型。
|
||||
- `app/data/audiobook`
|
||||
- 领域仓库、服务器配置映射和安全令牌存储。
|
||||
- `app/ui/audiobook`
|
||||
- Compose、MVI/UDF、Navigation 3 页面。
|
||||
- `app/service/audiobook`
|
||||
- 后续阶段新增的 Media3 `MediaSessionService`。
|
||||
|
||||
Audiobookshelf 条目不能映射为现有 `Book`/`BookChapter`,也不能直接写入旧版
|
||||
`AudioPlay` 的全局状态。远端条目统一使用 `serverId + libraryItemId` 标识。
|
||||
|
||||
## 认证与安全
|
||||
|
||||
- API 使用独立的标准 OkHttpClient,不复用且看支持书源规则的宽松 TLS 客户端。
|
||||
- 登录请求使用 `x-return-tokens: true`。
|
||||
- access token 仅保存在进程内存中。
|
||||
- refresh token 使用 Android Keystore 的 AES/GCM 密钥加密,并写入
|
||||
`noBackupFilesDir`,不进入且看备份。
|
||||
- 401 刷新采用 single-flight,避免刷新令牌并发轮换。
|
||||
- 服务器地址和用户名可随 `servers.json` 备份;密码和令牌不得进入配置 JSON。
|
||||
- 公网地址默认使用 HTTPS。HTTP 仅用于用户明确配置的可信局域网环境。
|
||||
|
||||
## 导航与隐私
|
||||
|
||||
首版入口位于“我的 > 有声书”,不增加默认第六个底部导航项。后续可将有声书加入
|
||||
主导航显示与排序设置,但默认保持隐藏。
|
||||
|
||||
后续接入且看首页“继续收听”、听书历史和统计时,必须在领域层统一应用隐私过滤,
|
||||
而不是由各个组件自行过滤。隐藏书库的内容不得进入首页、历史、统计和锁屏元数据。
|
||||
|
||||
## 实施阶段
|
||||
|
||||
1. 连接与浏览:服务器配置、登录、令牌刷新、媒体库和书籍列表。
|
||||
2. 播放:独立 Media3 Session、direct play、HLS、STRM、多音轨和进度同步。
|
||||
3. 体验融合:继续收听、迷你播放器、书签、历史、主导航和隐私联动。
|
||||
4. 离线:独立缓存数据库、断点续传、空间管理和离线进度回传。
|
||||
5. 高级能力:Socket.IO、OIDC、播客和 115 动态换链或代理。
|
||||
|
||||
## 第一阶段验收
|
||||
|
||||
- 可连接带根路径的 Audiobookshelf 地址,例如 `/audiobookshelf`。
|
||||
- 可使用用户名和密码登录,并列出当前账号可访问的媒体库。
|
||||
- 应用重启后可用加密 refresh token 恢复会话。
|
||||
- refresh token 无效时保留服务器地址和用户名,并要求重新输入密码。
|
||||
- WebDAV 服务器列表不显示 Audiobookshelf 类型的连接。
|
||||
- 旧备份恢复流程不变,备份文件中不包含 Audiobookshelf 密码或令牌。
|
||||
+2
-2
@@ -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) — 社区维护的文档
|
||||
|
||||
+12
-12
@@ -22,7 +22,7 @@ Java 类和方法。本文档列出所有可用的内置变量、对象属性和
|
||||
- `java` 变量已被 Legado 修改,调用 `java.*` 下的包请使用 `Packages.java.*`
|
||||
- 在源规则中使用 `@js`、`<js>`、<code v-pre>{{}}</code> 可调用 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)。
|
||||
|
||||
+1
-1
@@ -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` | 图片链接 |
|
||||
|
||||
|
||||
+2
-2
@@ -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: 📖
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 37
|
||||
namespace = "io.legado.audiobookshelf.client"
|
||||
|
||||
kotlin {
|
||||
jvmToolchain {
|
||||
languageVersion.set(JavaLanguageVersion.of(21))
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
targetCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
lint {
|
||||
checkDependencies = true
|
||||
targetSdk = 37
|
||||
}
|
||||
|
||||
testOptions {
|
||||
targetSdk = 37
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.okhttp)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
testImplementation(libs.junit)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<manifest />
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
package io.legado.audiobookshelf.client
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
|
||||
class AudiobookshelfApi(
|
||||
private val client: OkHttpClient = OkHttpClient.Builder().build()
|
||||
) {
|
||||
|
||||
fun getStatus(baseUrl: String): AudiobookshelfServerStatus {
|
||||
val request = requestBuilder(baseUrl, "status").get().build()
|
||||
val dto = execute<StatusDto>(request)
|
||||
if (dto.app != AUDIOBOOKSHELF_APP) {
|
||||
throw IllegalArgumentException("目标地址不是 Audiobookshelf 服务")
|
||||
}
|
||||
return AudiobookshelfServerStatus(
|
||||
version = dto.serverVersion,
|
||||
initialized = dto.isInit,
|
||||
authMethods = dto.authMethods
|
||||
)
|
||||
}
|
||||
|
||||
fun login(baseUrl: String, username: String, password: String): AudiobookshelfAuthSession {
|
||||
val body = json.encodeToString(LoginRequestDto(username, password))
|
||||
.toRequestBody(JSON_MEDIA_TYPE)
|
||||
val request = requestBuilder(baseUrl, "login")
|
||||
.header(RETURN_TOKENS_HEADER, "true")
|
||||
.post(body)
|
||||
.build()
|
||||
return execute<AuthResponseDto>(request).user.toSession()
|
||||
}
|
||||
|
||||
fun refresh(baseUrl: String, refreshToken: String): AudiobookshelfAuthSession {
|
||||
val request = requestBuilder(baseUrl, "auth/refresh")
|
||||
.header(REFRESH_TOKEN_HEADER, refreshToken)
|
||||
.post(EMPTY_BODY)
|
||||
.build()
|
||||
return execute<AuthResponseDto>(request).user.toSession()
|
||||
}
|
||||
|
||||
fun getLibraries(baseUrl: String, accessToken: String): List<AudiobookshelfLibrary> {
|
||||
val request = requestBuilder(baseUrl, "api/libraries")
|
||||
.header(AUTHORIZATION, "Bearer $accessToken")
|
||||
.get()
|
||||
.build()
|
||||
return execute<LibrariesResponseDto>(request).libraries.map { library ->
|
||||
AudiobookshelfLibrary(
|
||||
id = library.id,
|
||||
name = library.name,
|
||||
mediaType = library.mediaType,
|
||||
icon = library.icon
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestBuilder(baseUrl: String, path: String): Request.Builder {
|
||||
return Request.Builder()
|
||||
.url("${normalizeBaseUrl(baseUrl)}/${path.trimStart('/')}")
|
||||
.header(ACCEPT, "application/json")
|
||||
}
|
||||
|
||||
private inline fun <reified T> execute(request: Request): T {
|
||||
client.newCall(request).execute().use { response ->
|
||||
val responseBody = response.body.string()
|
||||
if (!response.isSuccessful) {
|
||||
throw AudiobookshelfApiException(
|
||||
statusCode = response.code,
|
||||
message = extractErrorMessage(responseBody)
|
||||
?: "Audiobookshelf 请求失败(HTTP ${response.code})"
|
||||
)
|
||||
}
|
||||
return runCatching { json.decodeFromString<T>(responseBody) }
|
||||
.getOrElse { error ->
|
||||
throw AudiobookshelfApiException(
|
||||
statusCode = response.code,
|
||||
message = "Audiobookshelf 返回了无法识别的数据:${error.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractErrorMessage(body: String): String? {
|
||||
return runCatching {
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
root["error"]?.jsonPrimitive?.contentOrNull
|
||||
?: root["message"]?.jsonPrimitive?.contentOrNull
|
||||
}.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val AUDIOBOOKSHELF_APP = "audiobookshelf"
|
||||
private const val ACCEPT = "Accept"
|
||||
private const val AUTHORIZATION = "Authorization"
|
||||
private const val RETURN_TOKENS_HEADER = "x-return-tokens"
|
||||
private const val REFRESH_TOKEN_HEADER = "x-refresh-token"
|
||||
private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType()
|
||||
private val EMPTY_BODY = ByteArray(0).toRequestBody(null)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
fun normalizeBaseUrl(input: String): String {
|
||||
val value = input.trim().trimEnd('/')
|
||||
val parsed = value.toHttpUrlOrNull()
|
||||
?: throw IllegalArgumentException("请输入完整的 HTTP 或 HTTPS 服务器地址")
|
||||
require(parsed.query == null && parsed.fragment == null) {
|
||||
"服务器地址不能包含查询参数或片段"
|
||||
}
|
||||
return parsed.toString().trimEnd('/')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class StatusDto(
|
||||
val app: String,
|
||||
val serverVersion: String = "",
|
||||
val isInit: Boolean = false,
|
||||
val authMethods: List<String> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class LoginRequestDto(
|
||||
val username: String,
|
||||
val password: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AuthResponseDto(
|
||||
val user: AuthUserDto
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class AuthUserDto(
|
||||
val username: String = "",
|
||||
val accessToken: String,
|
||||
val refreshToken: String
|
||||
) {
|
||||
fun toSession(): AudiobookshelfAuthSession {
|
||||
return AudiobookshelfAuthSession(
|
||||
username = username,
|
||||
accessToken = accessToken,
|
||||
refreshToken = refreshToken
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class LibrariesResponseDto(
|
||||
val libraries: List<LibraryDto> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class LibraryDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val mediaType: String,
|
||||
@SerialName("icon") val icon: String? = null
|
||||
)
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package io.legado.audiobookshelf.client
|
||||
|
||||
class AudiobookshelfApiException(
|
||||
val statusCode: Int,
|
||||
message: String
|
||||
) : Exception(message)
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package io.legado.audiobookshelf.client
|
||||
|
||||
data class AudiobookshelfServerStatus(
|
||||
val version: String,
|
||||
val initialized: Boolean,
|
||||
val authMethods: List<String>
|
||||
)
|
||||
|
||||
data class AudiobookshelfAuthSession(
|
||||
val username: String,
|
||||
val accessToken: String,
|
||||
val refreshToken: String
|
||||
)
|
||||
|
||||
data class AudiobookshelfLibrary(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val mediaType: String,
|
||||
val icon: String?
|
||||
)
|
||||
+30
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user