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