feat(backend): Phase 2.1 scaffolding for bookshelf-api + ABS

- Port BookshelfApiService, AudiobookshelfApiService and all models from Book's Story.
- Add Hilt 2.56.1 plugin, @HiltAndroidApp, NetworkModule, BackendModule.
- Add Retrofit/OkHttp/Kotlinx Serialization dependencies.
- Provide minimal SharedPreferences-based ServerSettings for URL/token storage.
- Copy UriUtils (fixUriScheme/normalizeUri) from Book's Story.
- No wiring into UI yet; OPDS/Gutenberg/LocalFolder still present.
- ':app:assembleOssDebug' passes (APK ~80 MB).
This commit is contained in:
Atte149 2026-06-24 11:15:24 +03:00
parent 9e30933077
commit 88113d9de6
25 changed files with 1070 additions and 0 deletions

View file

@ -11,6 +11,7 @@ plugins {
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.ksp) alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.hilt)
id("com.diffplug.spotless") version "8.2.1" id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover) alias(libs.plugins.kover)
} }
@ -228,6 +229,16 @@ dependencies {
implementation(libs.androidx.material3.window.size.class1.android) implementation(libs.androidx.material3.window.size.class1.android)
implementation(libs.androidx.credentials) implementation(libs.androidx.credentials)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
// Networking
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp.logging)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))

View file

@ -24,9 +24,11 @@ import android.webkit.WebView
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.decode.SvgDecoder import coil.decode.SvgDecoder
import dagger.hilt.android.HiltAndroidApp
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
import timber.log.Timber // Add this import timber.log.Timber // Add this
@HiltAndroidApp
class MyApplication : Application(), ImageLoaderFactory { class MyApplication : Application(), ImageLoaderFactory {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()

View file

@ -0,0 +1,76 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf
import android.util.Log
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.dueattendant149.bookreader.domain.util.fixUriScheme
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds (and caches) a Retrofit-backed [AudiobookshelfApiService] for the configured
* Audiobookshelf instance. All requests are authenticated with the provided bearer token.
*/
@Singleton
class AudiobookshelfApiClientFactory
@Inject
constructor(
private val json: Json,
private val okHttpClient: OkHttpClient,
) {
private var cachedUrl: String? = null
private var cachedToken: String? = null
private var cachedClient: AudiobookshelfApiService? = null
@Synchronized
fun provideClient(
url: String,
token: String,
): AudiobookshelfApiService? {
val fixedUrl = url.fixUriScheme()
if (fixedUrl == cachedUrl && token == cachedToken && cachedClient != null) {
return cachedClient
}
val authClient =
okHttpClient
.newBuilder()
.addInterceptor { chain ->
val request =
chain
.request()
.newBuilder()
.header("Authorization", "Bearer $token")
.build()
chain.proceed(request)
}
.addInterceptor(HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BASIC })
.build()
return runCatching {
Retrofit
.Builder()
.client(authClient)
.baseUrl(fixedUrl)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(AudiobookshelfApiService::class.java)
}.onFailure {
Log.e("AudiobookshelfApiFactory", "Failed to create client for $fixedUrl", it)
}.getOrNull().also {
cachedUrl = fixedUrl
cachedToken = token
cachedClient = it
}
}
fun clearCache() {
cachedUrl = null
cachedToken = null
cachedClient = null
}
}

View file

@ -0,0 +1,52 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Streaming
/**
* Direct Audiobookshelf API calls for audio files and covers.
* bookshelf-api does not proxy audio streams, so the app talks to ABS directly.
*/
interface AudiobookshelfApiService {
@Streaming
@GET("api/items/{itemId}/file/{fileId}")
suspend fun downloadAudioFile(
@Path("itemId") itemId: String,
@Path("fileId") fileId: String,
): Response<ResponseBody>
@Streaming
@GET("api/items/{itemId}/download")
suspend fun downloadBook(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/items/{itemId}/cover")
suspend fun getCover(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/me/progress/{itemId}")
suspend fun getProgress(
@Path("itemId") itemId: String,
): Response<String>
@GET("api/items/{itemId}")
suspend fun getItem(
@Path("itemId") itemId: String,
): Response<AbsItemResponse>
@POST("api/me/progress/{itemId}")
suspend fun updateProgress(
@Path("itemId") itemId: String,
@Body request: PlaybackProgressUpdateRequest,
): Response<String>
}

View file

@ -0,0 +1,21 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for updating media progress directly on an Audiobookshelf server.
*
* Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract.
*/
@Serializable
data class PlaybackProgressUpdateRequest(
@SerialName("currentTime")
val currentTime: Double,
@SerialName("duration")
val duration: Double,
@SerialName("progress")
val progress: Double,
@SerialName("episodeId")
val episodeId: String? = null,
)

View file

@ -0,0 +1,35 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AbsItemResponse(
val id: String = "",
val media: AbsMedia? = null,
)
@Serializable
data class AbsMedia(
val metadata: AbsMediaMetadata? = null,
@SerialName("audioFiles")
val audioFiles: List<AbsAudioFile> = emptyList(),
)
@Serializable
data class AbsMediaMetadata(
val title: String? = null,
)
@Serializable
data class AbsAudioFile(
val ino: String = "",
val metadata: AbsAudioFileMetadata? = null,
val duration: Double = 0.0,
)
@Serializable
data class AbsAudioFileMetadata(
val filename: String? = null,
val ext: String? = null,
)

View file

@ -0,0 +1,62 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi
import android.util.Log
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.dueattendant149.bookreader.domain.util.fixUriScheme
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Inject
import javax.inject.Singleton
/**
* Builds (and caches) a Retrofit-backed [BookshelfApiService] for the configured
* bookshelf-api instance.
*/
@Singleton
class BookshelfApiClientFactory
@Inject
constructor(
private val json: Json,
private val okHttpClient: OkHttpClient,
) {
private var cachedUrl: String? = null
private var cachedClient: BookshelfApiService? = null
@Synchronized
fun provideClient(url: String): BookshelfApiService? {
val fixedUrl = url.fixUriScheme()
if (fixedUrl == cachedUrl && cachedClient != null) {
return cachedClient
}
return runCatching {
Retrofit
.Builder()
.client(
okHttpClient
.newBuilder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
})
.build(),
)
.baseUrl(fixedUrl)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
.create(BookshelfApiService::class.java)
}.onFailure {
Log.e("BookshelfApiClientFactory", "Failed to create BookshelfApiService for $fixedUrl", it)
}.getOrNull().also {
cachedUrl = fixedUrl
cachedClient = it
}
}
fun clearCache() {
cachedUrl = null
cachedClient = null
}
}

View file

@ -0,0 +1,158 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PodcastResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.YandexResponse
import okhttp3.MultipartBody
import okhttp3.RequestBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.Multipart
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import retrofit2.http.Streaming
import retrofit2.http.POST
/**
* Retrofit description of the Bookshelf API (bookshelf-api:8073).
* All endpoints are relative to the configured base URL.
*/
interface BookshelfApiService {
// Health
@GET("health")
suspend fun health(): Response<Unit>
// Libraries / books
@GET("api/v1/books/libraries")
suspend fun getLibraries(): Response<List<LibraryResponse>>
@Streaming
@GET("api/v1/books/{itemId}/ebook")
suspend fun downloadEbook(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@GET("api/v1/books/{itemId}/tracks")
suspend fun getAudioTracks(
@Path("itemId") itemId: String,
): Response<List<AudioTrackResponse>>
@Streaming
@GET("api/v1/books/{itemId}/file/{fileId}")
suspend fun downloadAudioFile(
@Path("itemId") itemId: String,
@Path("fileId") fileId: String,
): Response<ResponseBody>
@Streaming
@GET("api/v1/books/{itemId}")
suspend fun getBook(
@Path("itemId") itemId: String,
): Response<ResponseBody>
@Streaming
@POST("api/v1/books/{itemId}/progress")
suspend fun updateReadingProgress(
@Path("itemId") itemId: String,
@Body request: ProgressUpdateRequest,
): Response<ResponseBody>
@Streaming
@POST("api/v1/books/{itemId}/playback-progress")
suspend fun updatePlaybackProgress(
@Path("itemId") itemId: String,
@Body request: PlaybackProgressUpdateRequest,
): Response<ResponseBody>
@GET("api/v1/books/library/{libraryId}/items")
suspend fun getLibraryItems(
@Path("libraryId") libraryId: String,
): Response<LibraryItemsResponse>
@GET("api/v1/books/library/{libraryId}/search")
suspend fun searchLibrary(
@Path("libraryId") libraryId: String,
@Query("q") query: String,
): Response<List<BookItemResponse>>
@POST("api/v1/search")
suspend fun search(
@Body request: SearchRequest,
): Response<SearchResponse>
// TTS
@GET("api/v1/tts/engines")
suspend fun getTtsEngines(): Response<TtsEnginesResponse>
@GET("api/v1/tts/voices")
suspend fun getTtsVoices(
@Query("engine") engine: String? = null,
): Response<TtsVoicesResponse>
@POST("api/v1/tts")
suspend fun createTtsJob(
@Body request: TtsCreateRequest,
): Response<TtsJobResponse>
@GET("api/v1/tts/{jobId}")
suspend fun getTtsJobStatus(
@Path("jobId") jobId: String,
): Response<TtsJobResponse>
@GET("api/v1/tts/jobs/list")
suspend fun listTtsJobs(): Response<TtsJobsResponse>
@Streaming
@GET("api/v1/tts/{jobId}/download")
suspend fun downloadTtsAudio(
@Path("jobId") jobId: String,
): Response<ResponseBody>
@Multipart
@POST("api/v1/upload/book")
suspend fun uploadBook(
@Part file: MultipartBody.Part,
@Part("book_type") bookType: RequestBody,
): Response<UploadBookResponse>
// Downloads / sources
@POST("api/v1/download")
suspend fun startDownload(
@Body request: DownloadRequest,
): Response<DownloadResponse>
@GET("api/v1/download/list")
suspend fun listDownloads(): Response<DownloadsListResponse>
@POST("api/v1/podcasts")
suspend fun downloadPodcast(
@Body request: PodcastRequest,
): Response<PodcastResponse>
@POST("api/v1/sources/yandex")
suspend fun downloadYandex(
@Body request: YandexRequest,
): Response<YandexResponse>
}

View file

@ -0,0 +1,61 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.data.remote.bookshelfapi
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.dueattendant149.bookreader.data.settings.ServerSettings
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ServerStatusMonitor
@Inject
constructor(
private val clientFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings,
) {
private val _isOnline = MutableStateFlow(false)
val isOnline = _isOnline.asStateFlow()
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var pingJob: Job? = null
fun start() {
if (pingJob != null) return
pingJob = scope.launch {
while (true) {
val online = checkHealth()
_isOnline.value = online
delay(30_000L)
}
}
}
fun stop() {
pingJob?.cancel()
pingJob = null
}
private suspend fun checkHealth(): Boolean {
val url = serverSettings.getBookshelfUrl() ?: return false
val client = clientFactory.provideClient(url) ?: return false
return runCatching {
val response = client.health()
response.isSuccessful
}.onFailure {
Log.d("ServerStatusMonitor", "Health check failed: ${it.message}")
}.getOrDefault(false)
}
}

View file

@ -0,0 +1,13 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class AudioTrackResponse(
@SerialName("file_id")
val fileId: String,
val title: String = "",
val duration: Double = 0.0,
val size: Long = 0L,
)

View file

@ -0,0 +1,19 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class BookItemResponse(
val id: String,
val title: String = "",
val author: String = "",
@SerialName("media_type")
val mediaType: String = "",
@SerialName("library_id")
val libraryId: String = "",
@SerialName("cover_url")
val coverUrl: String = "",
val duration: Double = 0.0,
val size: Long = 0L,
)

View file

@ -0,0 +1,75 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class DownloadRequest(
val source: String = "",
val title: String = "",
val author: String = "",
@SerialName("download_url")
val downloadUrl: String? = null,
@SerialName("magnet_url")
val magnetUrl: String? = null,
@SerialName("info_hash")
val infoHash: String? = null,
val md5: String? = null,
val url: String? = null,
@SerialName("media_type")
val mediaType: String = "ebook",
@SerialName("download_protocol")
val downloadProtocol: String = "",
)
@Serializable
data class DownloadResponse(
val success: Boolean = false,
val error: String? = null,
@SerialName("download_id")
val downloadId: String? = null,
)
@Serializable
data class DownloadStatusResponse(
val title: String = "",
val status: String = "",
val progress: Double = 0.0,
val speed: String = "",
val error: String? = null,
)
@Serializable
data class DownloadsListResponse(
val downloads: List<DownloadStatusResponse> = emptyList(),
)
@Serializable
data class PodcastRequest(
val url: String,
val title: String? = null,
val format: String = "m4a",
)
@Serializable
data class PodcastResponse(
val url: String = "",
val title: String = "",
val tracks: Int = 0,
val files: List<String> = emptyList(),
@SerialName("abs_scan_triggered")
val absScanTriggered: Boolean = false,
)
@Serializable
data class YandexRequest(
val bookid: String,
)
@Serializable
data class YandexResponse(
val bookid: String,
val files: List<String> = emptyList(),
@SerialName("abs_scan_triggered")
val absScanTriggered: Boolean = false,
)

View file

@ -0,0 +1,14 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LibraryResponse(
val id: String,
val name: String = "",
@SerialName("media_type")
val mediaType: String = "",
@SerialName("item_count")
val itemCount: Int = 0,
)

View file

@ -0,0 +1,20 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for updating the audio playback progress of a book on the bookshelf-api.
*/
@Serializable
data class PlaybackProgressUpdateRequest(
val itemId: String,
val libraryId: String,
@SerialName("current_file")
val currentFile: String,
@SerialName("current_position")
val currentPosition: Long,
val duration: Long,
@SerialName("updated_at")
val updatedAt: Long,
)

View file

@ -0,0 +1,20 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Request body for updating the local reading progress of a book on the bookshelf-api.
*/
@Serializable
data class ProgressUpdateRequest(
val itemId: String,
val libraryId: String,
val scrollIndex: Int,
val scrollOffset: Int,
val progress: Float,
@SerialName("last_chapter")
val lastChapter: String? = null,
@SerialName("updated_at")
val updatedAt: Long,
)

View file

@ -0,0 +1,54 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class SearchResponse(
val results: List<SearchResultItemResponse> = emptyList(),
@SerialName("search_time_ms")
val searchTimeMs: Int = 0,
val total: Int = 0,
)
@Serializable
data class SearchResultItemResponse(
val source: String = "",
val title: String = "",
val author: String = "",
val format: String = "",
@SerialName("media_type")
val mediaType: String = "",
@SerialName("size_human")
val sizeHuman: String = "",
val seeders: Int? = null,
val score: Double = 0.0,
val guid: String = "",
val md5: String = "",
@SerialName("magnet_url")
val magnetUrl: String = "",
@SerialName("download_url")
val downloadUrl: String = "",
val url: String = "",
@SerialName("cover_url")
val coverUrl: String = "",
@SerialName("info_hash")
val infoHash: String = "",
@SerialName("download_protocol")
val downloadProtocol: String = "",
)
@Serializable
data class SearchRequest(
val query: String,
val author: String? = null,
@SerialName("media_type")
val mediaType: String? = null,
val format: String? = null,
val language: String? = null,
@SerialName("year_from")
val yearFrom: Int? = null,
@SerialName("year_to")
val yearTo: Int? = null,
val limit: Int = 50,
)

View file

@ -0,0 +1,91 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class TtsCreateRequest(
@SerialName("book_id")
val bookId: String,
val engine: String = "silero",
@SerialName("voice_id")
val voiceId: String = "",
val speed: Double = 1.0,
)
@Serializable
data class TtsJobResponse(
@SerialName("job_id")
val jobId: String,
@SerialName("book_id")
val bookId: String,
val title: String = "",
val author: String = "",
val engine: String = "",
@SerialName("voice_id")
val voiceId: String = "",
val speed: Double = 1.0,
val status: String = "",
val progress: Double = 0.0,
@SerialName("current_chapter")
val currentChapter: String = "",
@SerialName("total_chapters")
val totalChapters: Int = 0,
@SerialName("completed_chapters")
val completedChapters: Int = 0,
@SerialName("output_path")
val outputPath: String = "",
val error: String = "",
@SerialName("created_at")
val createdAt: Double = 0.0,
@SerialName("started_at")
val startedAt: Double = 0.0,
@SerialName("completed_at")
val completedAt: Double = 0.0,
)
@Serializable
data class TtsJobsResponse(
val jobs: List<TtsJobResponse> = emptyList(),
)
@Serializable
data class TtsEnginesResponse(
val engines: List<TtsEngineResponse> = emptyList(),
)
@Serializable
data class TtsVoicesResponse(
val voices: List<TtsVoiceResponse> = emptyList(),
)
@Serializable
data class TtsEngineResponse(
val id: String,
val name: String,
val capabilities: TtsEngineCapabilitiesResponse,
)
@Serializable
data class TtsEngineCapabilitiesResponse(
@SerialName("supports_streaming")
val supportsStreaming: Boolean = false,
@SerialName("supports_cloning")
val supportsCloning: Boolean = false,
@SerialName("max_text_length")
val maxTextLength: Int = 5000,
@SerialName("needs_network")
val needsNetwork: Boolean = false,
)
@Serializable
data class TtsVoiceResponse(
val id: String,
val name: String,
val language: String,
val engine: String,
val gender: String = "",
val quality: String = "",
@SerialName("requires_reference")
val requiresReference: Boolean = false,
)

View file

@ -0,0 +1,67 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LibraryItemsResponse(
val library: LibraryResponse = LibraryResponse(""),
val items: List<UnifiedItemResponse> = emptyList(),
)
@Serializable
data class UnifiedItemResponse(
val id: String,
val title: String = "",
val authors: List<String> = emptyList(),
val author: String = "",
val type: String = "",
@SerialName("media_type")
val mediaType: String = "",
@SerialName("library_id")
val libraryId: String = "",
@SerialName("cover_url")
val coverUrl: String = "",
val duration: Double = 0.0,
val size: Long = 0L,
@SerialName("progress")
val progress: UnifiedItemProgressResponse? = null,
)
@Serializable
data class UnifiedItemProgressResponse(
val reading: ReadingProgressResponse? = null,
val playback: PlaybackProgressResponse? = null,
)
@Serializable
data class ReadingProgressResponse(
@SerialName("itemId")
val itemId: String = "",
@SerialName("libraryId")
val libraryId: String = "",
@SerialName("scrollIndex")
val scrollIndex: Int = 0,
@SerialName("scrollOffset")
val scrollOffset: Int = 0,
val progress: Float = 0f,
@SerialName("last_chapter")
val lastChapter: String? = null,
@SerialName("updatedAt")
val updatedAt: Long = 0L,
)
@Serializable
data class PlaybackProgressResponse(
@SerialName("itemId")
val itemId: String = "",
@SerialName("libraryId")
val libraryId: String = "",
@SerialName("current_file")
val currentFile: String = "",
@SerialName("current_position")
val currentPosition: Long = 0L,
val duration: Long = 0L,
@SerialName("updatedAt")
val updatedAt: Long = 0L,
)

View file

@ -0,0 +1,14 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi.model
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class UploadBookResponse(
val success: Boolean = false,
val filename: String = "",
val type: String = "",
val path: String = "",
@SerialName("error")
val errorMessage: String? = null,
)

View file

@ -0,0 +1,44 @@
package org.dueattendant149.bookreader.data.settings
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
private const val PREFS_NAME = "book_reader_server_settings"
private const val KEY_BOOKSHELF_URL = "bookshelf_url"
private const val KEY_ABS_URL = "abs_url"
private const val KEY_ABS_TOKEN = "abs_token"
/**
* Minimal server URL/token storage. Replaces the full DataStore-based settings
* from Book's Story for Phase 2 backend scaffolding.
*/
@Singleton
class ServerSettings
@Inject
constructor(
@ApplicationContext context: Context,
) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
fun getBookshelfUrl(): String? = prefs.getString(KEY_BOOKSHELF_URL, null)
fun setBookshelfUrl(url: String) {
prefs.edit { putString(KEY_BOOKSHELF_URL, url) }
}
fun getAbsUrl(): String? = prefs.getString(KEY_ABS_URL, null)
fun setAbsUrl(url: String) {
prefs.edit { putString(KEY_ABS_URL, url) }
}
fun getAbsToken(): String? = prefs.getString(KEY_ABS_TOKEN, null)
fun setAbsToken(token: String) {
prefs.edit { putString(KEY_ABS_TOKEN, token) }
}
}

View file

@ -0,0 +1,37 @@
package org.dueattendant149.bookreader.di
import android.content.Context
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import org.dueattendant149.bookreader.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiClientFactory
import org.dueattendant149.bookreader.data.settings.ServerSettings
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object BackendModule {
@Provides
@Singleton
fun provideServerSettings(@ApplicationContext context: Context): ServerSettings = ServerSettings(context)
@Provides
@Singleton
fun provideBookshelfApiClientFactory(
json: Json,
okHttpClient: OkHttpClient,
): BookshelfApiClientFactory = BookshelfApiClientFactory(json, okHttpClient)
@Provides
@Singleton
fun provideAudiobookshelfApiClientFactory(
json: Json,
okHttpClient: OkHttpClient,
): AudiobookshelfApiClientFactory = AudiobookshelfApiClientFactory(json, okHttpClient)
}

View file

@ -0,0 +1,34 @@
package org.dueattendant149.bookreader.di
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideJson(): Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
isLenient = true
}
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient
.Builder()
.addInterceptor(
HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BASIC
}
)
.build()
}

View file

@ -0,0 +1,76 @@
package org.dueattendant149.bookreader.domain.util
import android.net.Uri
private val URL_SCHEME_REGEX = Regex("^[hH][tT][tT][pP][sS]?://")
/**
* Returns true if the string is non-blank, has an http/https scheme,
* and can be parsed by [Uri.parse] into a host-bearing URI.
*/
fun String.isValidUri(): Boolean {
val trimmed = trim()
if (trimmed.isBlank()) return false
if (!trimmed.hasUriScheme()) return false
val uri = runCatching { Uri.parse(trimmed) }.getOrNull() ?: return false
return !uri.host.isNullOrBlank()
}
/**
* Returns true if the string starts with http:// or https:// (case-insensitive).
*/
fun String.hasUriScheme(): Boolean = URL_SCHEME_REGEX.containsMatchIn(this)
private val IP_PATTERN = Regex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d+)?$")
/**
* Adds https:// (or http:// for bare IP addresses) if the string has no scheme.
*/
fun String.ensureUriScheme(): String {
val trimmed = trim()
if (trimmed.hasUriScheme()) return trimmed
val hostPart = trimmed.substringBefore("/")
val scheme = if (IP_PATTERN.matches(hostPart)) "http://" else "https://"
return "$scheme$trimmed"
}
/**
* Adds a trailing slash if missing.
*/
fun String.ensureTrailingSlash(): String {
val trimmed = trim()
return if (trimmed.endsWith("/")) trimmed else "$trimmed/"
}
/**
* Normalizes a URL for Retrofit: trims whitespace, adds a scheme if missing,
* and ensures a trailing slash.
*/
fun String.normalizeUri(): String = ensureUriScheme().ensureTrailingSlash()
/**
* Legacy alias for [normalizeUri].
*/
fun String.fixUriScheme(): String = normalizeUri()
/**
* Derives a likely Audiobookshelf URL from a Bookshelf API URL.
* Replaces a known bookshelf-api port (8073) with the default ABS port (13378),
* or appends :13378 when no port is present. Returns null if the input is invalid.
*/
fun String.deriveAbsUrl(): String? {
if (!isValidUri()) return null
val normalized = normalizeUri()
val uri = Uri.parse(normalized)
val host = uri.host ?: return null
val port = uri.port
val scheme = uri.scheme?.lowercase() ?: "https"
val derivedPort = when (port) {
8073 -> 13378
-1 -> 13378
else -> port
}
return "$scheme://$host:$derivedPort/"
}

View file

@ -6,6 +6,7 @@ plugins {
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.kotlin.ksp) apply false alias(libs.plugins.kotlin.ksp) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.kover) apply false alias(libs.plugins.kover) apply false
} }

View file

@ -23,6 +23,11 @@ material3WindowSizeClassAndroid = "1.3.2"
credentials = "1.5.0" credentials = "1.5.0"
composeMultiplatform = "1.8.2" composeMultiplatform = "1.8.2"
ksp = "2.2.0-2.0.2" ksp = "2.2.0-2.0.2"
hilt = "2.56.1"
hiltNavigationCompose = "1.2.0"
retrofit = "2.11.0"
retrofitKotlinxSerialization = "2.11.0"
okhttpLogging = "4.12.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@ -56,6 +61,13 @@ androidx-runner = { group = "androidx.test", name = "runner", version.ref = "and
androidx-material3-window-size-class1-android = { group = "androidx.compose.material3", name = "material3-window-size-class-android", version.ref = "material3WindowSizeClassAndroid" } androidx-material3-window-size-class1-android = { group = "androidx.compose.material3", name = "material3-window-size-class-android", version.ref = "material3WindowSizeClassAndroid" }
androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" } androidx-credentials = { group = "androidx.credentials", name = "credentials", version.ref = "credentials" }
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofitKotlinxSerialization" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttpLogging" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
@ -65,6 +77,7 @@ kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "ko
kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
# Add plugins required by pdfiumandroid # Add plugins required by pdfiumandroid
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }