feat: release QieKan 3.26.15
Auto Release / build (app, release) (push) Canceled after 0s
Auto Release / create_release (push) Canceled after 0s
Auto Release / prepare (push) Canceled after 0s
CodeQL / Analyze (java-kotlin) (java-kotlin) (push) Canceled after 0s
Deploy Docs / build (push) Canceled after 0s
Deploy Docs / Deploy (push) Canceled after 0s

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