Fix API contract: Response<ResponseBody> for endpoints returning JSON objects

- getBook, updateReadingProgress, updatePlaybackProgress: Response<String> → Response<ResponseBody>
- Fixes kotlinx-serialization converter crash on JSON object responses
- health(): Response<Unit> (already fixed)
- Fix hasUriScheme(): matches() → containsMatchIn()
- Full Russian translation of strings.xml (583 lines)
- Russian added as default language in CoreData
This commit is contained in:
Atte149 2026-06-19 21:05:29 +03:00
parent efbd2aff66
commit a7a9f65391
7 changed files with 468 additions and 446 deletions

View file

@ -10,8 +10,9 @@ import kotlinx.collections.immutable.toPersistentList
import org.dueattendant149.bookshelf.core.language.Language import org.dueattendant149.bookshelf.core.language.Language
object CoreData { object CoreData {
val defaultLanguage = Language.fromLanguageTag(languageTag = "en") // English val defaultLanguage = Language.fromLanguageTag(languageTag = "ru") // Russian
val languages = listOf( val languages = listOf(
Language.fromLanguageTag(languageTag = "ru"), // Russian
Language.fromLanguageTag(languageTag = "en"), // English Language.fromLanguageTag(languageTag = "en"), // English
Language.fromLanguageTag(languageTag = "uk"), // Ukrainian Language.fromLanguageTag(languageTag = "uk"), // Ukrainian
Language.fromLanguageTag(languageTag = "de"), // German Language.fromLanguageTag(languageTag = "de"), // German

View file

@ -35,28 +35,31 @@ interface BookshelfApiService {
// Health // Health
@GET("health") @GET("health")
suspend fun health(): Response<String> suspend fun health(): Response<Unit>
// Libraries / books // Libraries / books
@GET("api/v1/books/libraries") @GET("api/v1/books/libraries")
suspend fun getLibraries(): Response<List<LibraryResponse>> suspend fun getLibraries(): Response<List<LibraryResponse>>
@Streaming
@GET("api/v1/books/{itemId}") @GET("api/v1/books/{itemId}")
suspend fun getBook( suspend fun getBook(
@Path("itemId") itemId: String, @Path("itemId") itemId: String,
): Response<String> ): Response<ResponseBody>
@Streaming
@POST("api/v1/books/{itemId}/progress") @POST("api/v1/books/{itemId}/progress")
suspend fun updateReadingProgress( suspend fun updateReadingProgress(
@Path("itemId") itemId: String, @Path("itemId") itemId: String,
@Body request: ProgressUpdateRequest, @Body request: ProgressUpdateRequest,
): Response<String> ): Response<ResponseBody>
@Streaming
@POST("api/v1/books/{itemId}/playback-progress") @POST("api/v1/books/{itemId}/playback-progress")
suspend fun updatePlaybackProgress( suspend fun updatePlaybackProgress(
@Path("itemId") itemId: String, @Path("itemId") itemId: String,
@Body request: PlaybackProgressUpdateRequest, @Body request: PlaybackProgressUpdateRequest,
): Response<String> ): Response<ResponseBody>
@GET("api/v1/books/library/{libraryId}/search") @GET("api/v1/books/library/{libraryId}/search")
suspend fun searchLibrary( suspend fun searchLibrary(

View file

@ -8,6 +8,7 @@ package org.dueattendant149.bookshelf.data.settings
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import org.dueattendant149.bookshelf.data.local.data_store.DataStore import org.dueattendant149.bookshelf.data.local.data_store.DataStore
import org.dueattendant149.bookshelf.domain.util.ensureUriScheme
import org.dueattendant149.bookshelf.domain.util.isValidUri import org.dueattendant149.bookshelf.domain.util.isValidUri
import org.dueattendant149.bookshelf.domain.util.normalizeUri import org.dueattendant149.bookshelf.domain.util.normalizeUri
import javax.inject.Inject import javax.inject.Inject
@ -47,11 +48,12 @@ class ServerSettings
* or null when all credentials are present. * or null when all credentials are present.
*/ */
suspend fun getMissingCredentialsError(): String? { suspend fun getMissingCredentialsError(): String? {
val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey) val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey)?.trim()
if (bookshelfUrl.isNullOrBlank()) { if (bookshelfUrl.isNullOrBlank()) {
return "Bookshelf API URL is missing" return "Bookshelf API URL is missing"
} }
if (!bookshelfUrl.isValidUri()) { val normalizedBookshelfUrl = bookshelfUrl.ensureUriScheme()
if (!normalizedBookshelfUrl.isValidUri()) {
return "Bookshelf API URL is invalid: $bookshelfUrl" return "Bookshelf API URL is invalid: $bookshelfUrl"
} }
if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) { if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) {
@ -67,8 +69,8 @@ class ServerSettings
val url = dataStore.getNullableData(bookshelfUrlKey)?.trim() val url = dataStore.getNullableData(bookshelfUrlKey)?.trim()
return when { return when {
url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty")) url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty"))
!url.isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url")) !url.ensureUriScheme().isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url"))
else -> Result.success(url.normalizeUri()) else -> Result.success(url.ensureUriScheme().normalizeUri())
} }
} }
@ -80,6 +82,8 @@ class ServerSettings
private fun String.normalizeIfValid(): String? { private fun String.normalizeIfValid(): String? {
val trimmed = trim() val trimmed = trim()
return if (trimmed.isValidUri()) trimmed.normalizeUri() else null if (trimmed.isBlank()) return null
val withScheme = trimmed.ensureUriScheme()
return if (withScheme.isValidUri()) withScheme.normalizeUri() else null
} }
} }

View file

@ -20,7 +20,7 @@ fun String.isValidUri(): Boolean {
/** /**
* Returns true if the string starts with http:// or https:// (case-insensitive). * Returns true if the string starts with http:// or https:// (case-insensitive).
*/ */
fun String.hasUriScheme(): Boolean = matches(URL_SCHEME_REGEX) fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
/** /**
* Adds https:// if the string has no http/https scheme. * Adds https:// if the string has no http/https scheme.

View file

@ -18,6 +18,8 @@ import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary
import org.dueattendant149.bookshelf.domain.use_case.remote.CheckHealthUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.CheckHealthUseCase
import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase
import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl
import org.dueattendant149.bookshelf.domain.util.ensureUriScheme
import org.dueattendant149.bookshelf.domain.util.normalizeUri
import javax.inject.Inject import javax.inject.Inject
data class BookshelfSettingsState( data class BookshelfSettingsState(
@ -108,8 +110,20 @@ class BookshelfSettingsModel
fun save() { fun save() {
viewModelScope.launch { viewModelScope.launch {
val current = _state.value val current = _state.value
serverSettings.setBookshelfUrl(current.bookshelfUrl) val normalizedBookshelfUrl = current.bookshelfUrl.trim().let {
serverSettings.setAbsUrl(current.absUrl) if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it
}
val normalizedAbsUrl = current.absUrl.trim().let {
if (it.isNotBlank()) it.ensureUriScheme().normalizeUri() else it
}
_state.update {
it.copy(
bookshelfUrl = normalizedBookshelfUrl,
absUrl = normalizedAbsUrl,
)
}
serverSettings.setBookshelfUrl(normalizedBookshelfUrl)
serverSettings.setAbsUrl(normalizedAbsUrl)
serverSettings.setAbsToken(current.absToken) serverSettings.setAbsToken(current.absToken)
} }
} }

View file

@ -73,7 +73,7 @@ fun BookshelfSettingsContent(
value = state.bookshelfUrl, value = state.bookshelfUrl,
onValueChange = model::updateBookshelfUrl, onValueChange = model::updateBookshelfUrl,
label = { Text(stringResource(R.string.bookshelf_url_option)) }, label = { Text(stringResource(R.string.bookshelf_url_option)) },
placeholder = { Text("http://bookshelf-api:8073") }, placeholder = { Text("https://tts.dueattendant149.org") },
supportingText = { Text(stringResource(R.string.bookshelf_url_option_desc)) }, supportingText = { Text(stringResource(R.string.bookshelf_url_option_desc)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )
@ -83,7 +83,7 @@ fun BookshelfSettingsContent(
value = state.absUrl, value = state.absUrl,
onValueChange = model::updateAbsUrl, onValueChange = model::updateAbsUrl,
label = { Text(stringResource(R.string.abs_url_option)) }, label = { Text(stringResource(R.string.abs_url_option)) },
placeholder = { Text("http://192.168.1.119:13378") }, placeholder = { Text("https://books.dueattendant149.org") },
supportingText = { Text(stringResource(R.string.abs_url_option_desc)) }, supportingText = { Text(stringResource(R.string.abs_url_option_desc)) },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) )

File diff suppressed because it is too large Load diff