Merge feature/ebook: open remote ebooks from RemoteLibrary

This commit is contained in:
Atte149 2026-06-16 12:12:01 +03:00
commit c8c1503d8c
12 changed files with 286 additions and 179 deletions

View file

@ -68,8 +68,12 @@
- Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed.
- Phase 3: data layer (remote Book fields, audio/ebook/cache tables, migration 16→17) completed.
- Phase 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed.
- Phase 5: UI scaffold (server settings screen, RemoteLibrary tab) completed.
- Next: audio player (ExoPlayer + MediaSession), TTS integration, offline cache, reader integration for ebooks.
- Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed.
- Phase 5b: UI/UX polish (icons, string resources, pull-to-refresh, empty/error states) merged.
- Phase 5c: server settings validation (health check, derive ABS URL, credential checks) merged.
- Phase 6a: offline cache scaffold (CacheRepository, CacheDownloadWorker, cache use cases) merged into model.
- Current: `feature/ui-polish` builds and passes `:app:compileDebugKotlin`.
- Next: audio player (ExoPlayer + MediaSession), TTS UI + jobs, cache UI in RemoteLibrary, reader integration for ebooks, progress sync.
## WARNs

View file

@ -37,6 +37,7 @@ import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
import org.dueattendant149.bookshelf.domain.repository.BookRepository
import org.dueattendant149.bookshelf.domain.repository.CacheRepository
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
@ -96,6 +97,12 @@ abstract class RepositoryModule {
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
): RemoteLibraryRepository
@Binds
@Singleton
abstract fun bindCacheRepository(
cacheRepositoryImpl: CacheRepositoryImpl
): CacheRepository
@Binds
@Singleton
abstract fun bindBookMapper(

View file

@ -50,6 +50,11 @@ class CachedFile(
val isDirectory: Boolean get() = builder?.isDirectory ?: queryParams.isDirectory
fun canAccess(): Boolean {
if (uri.scheme == "file") {
val file = File(uri.path ?: return false)
return file.exists() && file.canRead()
}
return try {
context.contentResolver.query(uri, null, null, null, null)?.let {
it.close()

View file

@ -7,24 +7,17 @@
package org.dueattendant149.bookshelf.data.repository
import android.app.Application
import androidx.work.Constraints
import androidx.work.Data
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import android.webkit.URLUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.local.dto.BookEntity
import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.data.worker.CacheDownloadWorker
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
@ -38,194 +31,159 @@ import javax.inject.Singleton
class CacheRepositoryImpl
@Inject
constructor(
private val application: Application,
private val database: BookDatabase,
private val bookMapper: BookMapper,
private val apiFactory: AudiobookshelfApiClientFactory,
private val serverSettings: ServerSettings,
private val workManager: WorkManager,
private val application: Application,
) : CacheRepository {
override suspend fun cacheBook(book: Book): Result<Unit> = runCatching {
private val cachedFileDao by lazy { database.cachedFileDao }
override suspend fun cacheBook(book: Book): Result<Int> = runCatching {
withContext(Dispatchers.IO) {
val localBook = findOrInsertBook(book)
val bookId = localBook.id
val absUrl = serverSettings.getAbsUrl() ?: error("Audiobookshelf URL not configured")
require(book.hasEbook) { "Book has no ebook." }
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
if (book.coverUrl.isNotBlank()) {
createCacheRecord(bookId, "cover", book.coverUrl)
}
if (book.hasEbook) {
createCacheRecord(
bookId,
"ebook",
"$absUrl/api/items/${book.remoteId}/download",
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
if (existing != null &&
cachedEbook?.localPath != null &&
File(cachedEbook.localPath).exists()
) {
if (existing.filePath != cachedEbook.localPath) {
database.bookDao.updateBook(
bookMapper.toBookEntity(
bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
)
)
}
if (book.hasAudio) {
createCacheRecord(
bookId,
"audio",
"$absUrl/api/items/${book.remoteId}/download",
)
return@withContext existing.id
}
val inputData =
Data
.Builder()
.putInt(CacheDownloadWorker.KEY_BOOK_ID, bookId)
.putString(CacheDownloadWorker.KEY_REMOTE_ID, book.remoteId)
.putString(CacheDownloadWorker.KEY_TITLE, book.title)
.putString(CacheDownloadWorker.KEY_MEDIA_TYPE, book.mediaType)
.putBoolean(CacheDownloadWorker.KEY_HAS_AUDIO, book.hasAudio)
.putBoolean(CacheDownloadWorker.KEY_HAS_EBOOK, book.hasEbook)
.putString(CacheDownloadWorker.KEY_COVER_URL, book.coverUrl)
.build()
val url = serverSettings.getAbsUrl()
val token = serverSettings.getAbsToken()
if (url.isNullOrBlank() || token.isNullOrBlank()) {
throw IllegalStateException("Audiobookshelf server is not configured.")
}
val request =
OneTimeWorkRequestBuilder<CacheDownloadWorker>()
.setInputData(inputData)
.setConstraints(
Constraints
.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build(),
)
.build()
val client = apiFactory.provideClient(url, token)
?: throw IllegalStateException("Could not create Audiobookshelf client.")
workManager.enqueueUniqueWork(
CacheDownloadWorker.workName(bookId),
ExistingWorkPolicy.KEEP,
request,
val response = client.downloadBook(book.remoteId)
if (!response.isSuccessful) {
throw IllegalStateException("Download failed: ${response.code()}")
}
val body = response.body()
?: throw IllegalStateException("Download response body is empty.")
val ebooksDir = File(application.filesDir, "ebooks").apply { mkdirs() }
val fileName = guessFileName(response, book.remoteId)
val file = File(ebooksDir, fileName)
body.byteStream().use { input ->
file.outputStream().use { output ->
input.copyTo(output)
}
}
val localBook = if (existing != null) {
book.copy(
id = existing.id,
filePath = file.absolutePath,
scrollIndex = existing.scrollIndex,
scrollOffset = existing.scrollOffset,
progress = existing.progress,
categories = existing.categories,
)
} else {
book.copy(filePath = file.absolutePath)
}
database.bookDao.insertBook(bookMapper.toBookEntity(localBook))
val inserted = database.bookDao.findBookByRemoteId(book.remoteId)
?: throw IllegalStateException("Could not insert or update book.")
database.ebookFileDao.insert(
EbookFileEntity(
bookId = inserted.id,
fileId = book.remoteId,
format = file.extension,
localPath = file.absolutePath,
)
)
cachedFileDao.insert(
CachedFileEntity(
bookId = inserted.id,
type = "ebook",
remoteUrl = "$url/api/items/${book.remoteId}/download",
localPath = file.absolutePath,
status = "completed",
progress = 1f,
)
)
inserted.id
}
}
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
withContext(Dispatchers.IO) {
val book = database.bookDao.findBookByRemoteId(remoteId) ?: return@withContext
val records = database.cachedFileDao.getByBookId(book.id)
records.forEach { record ->
record.localPath?.let { path ->
File(path).delete()
}
database.cachedFileDao.delete(record)
}
workManager.cancelUniqueWork(CacheDownloadWorker.workName(book.id))
val book = database.bookDao.findBookByRemoteId(remoteId)
?: throw IllegalStateException("Book not found")
val ebook = database.ebookFileDao.getByBookId(book.id)
val cachedFiles = cachedFileDao.getByBookId(book.id)
ebook?.localPath?.let { File(it).delete() }
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
database.ebookFileDao.deleteByBookId(book.id)
cachedFileDao.deleteByBookId(book.id)
}
}
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> =
flow {
val bookId = database.bookDao.findBookByRemoteId(remoteId)?.id
if (bookId == null) {
emit(
CacheStatus(
remoteId = remoteId,
state = CacheState.NONE,
progress = 0f,
cachedFiles = emptyList(),
),
)
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
val book = database.bookDao.findBookByRemoteId(remoteId)
if (book == null) {
emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
return@flow
}
database.cachedFileDao.observeByBookId(bookId).map { records ->
records.toCacheStatus(remoteId)
}.collect { emit(it) }
}.flowOn(Dispatchers.IO)
private suspend fun findOrInsertBook(book: Book): BookEntity {
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
return if (existing != null) {
val updated =
existing.copy(
title = book.title,
author = book.author.getAsString() ?: "",
coverUrl = book.coverUrl,
hasAudio = book.hasAudio,
hasEbook = book.hasEbook,
audioDuration = book.audioDuration,
mediaType = book.mediaType,
libraryId = book.libraryId,
lastSyncedAt = System.currentTimeMillis(),
)
database.bookDao.updateBook(updated)
database.bookDao.findBookByRemoteId(book.remoteId) ?: updated
} else {
val entity = bookMapper.toBookEntity(book.copy(filePath = ""))
database.bookDao.insertBook(entity)
database.bookDao.findBookByRemoteId(book.remoteId)
?: error("Failed to insert book for caching")
}
}
private suspend fun createCacheRecord(
bookId: Int,
type: String,
url: String,
) {
val existing = database.cachedFileDao.getByBookIdAndTypeSingle(bookId, type)
if (existing == null) {
database.cachedFileDao.insert(
CachedFileEntity(
bookId = bookId,
type = type,
remoteUrl = url,
status = "pending",
progress = 0f,
),
)
} else if (existing.status == "completed" &&
existing.localPath != null &&
File(existing.localPath).exists()
) {
// Already cached locally, leave it alone.
} else {
database.cachedFileDao.update(
existing.copy(status = "pending", progress = 0f),
)
}
}
private fun List<CachedFileEntity>.toCacheStatus(remoteId: String): CacheStatus {
if (isEmpty()) {
return CacheStatus(
remoteId = remoteId,
state = CacheState.NONE,
progress = 0f,
cachedFiles = emptyList(),
)
}
val files =
map {
cachedFileDao.observeByBookId(book.id).collect { entities ->
val files = entities.map {
CachedFile(
type = it.type,
status = it.status,
progress = it.progress,
localPath = it.localPath,
localPath = it.localPath
)
}
val state =
when {
files.any { it.status == "failed" } -> CacheState.FAILED
files.any { it.status == "downloading" } -> CacheState.DOWNLOADING
files.all { it.status == "completed" } -> CacheState.COMPLETED
else -> CacheState.PENDING
val state = when {
entities.isEmpty() -> CacheState.NONE
entities.all { it.status == "completed" } -> CacheState.COMPLETED
entities.any { it.status == "failed" } -> CacheState.FAILED
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
entities.any { it.status == "pending" } -> CacheState.PENDING
else -> CacheState.NONE
}
val progress = if (entities.isEmpty()) {
0f
} else {
entities.map { it.progress }.average().toFloat()
}
emit(CacheStatus(remoteId, state, progress, files))
}
}
val progress =
when (state) {
CacheState.DOWNLOADING -> files.filter { it.status == "downloading" }.map { it.progress }.average().toFloat()
CacheState.COMPLETED -> 1f
else -> 0f
}
return CacheStatus(
remoteId = remoteId,
state = state,
progress = progress,
cachedFiles = files,
)
private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String {
val contentDisposition = response.headers()["Content-Disposition"]
val mimeType = response.body()?.contentType()?.toString()
val url = response.raw().request.url.toString()
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
return if (name.contains(".")) name else "$remoteId.epub"
}
}

View file

@ -7,10 +7,12 @@
package org.dueattendant149.bookshelf.data.service
import android.app.Application
import android.net.Uri
import org.dueattendant149.bookshelf.data.model.file.CachedFile
import org.dueattendant149.bookshelf.data.model.file.CachedFileCompat
import org.dueattendant149.bookshelf.domain.model.library.Book
import org.dueattendant149.bookshelf.domain.service.FileProvider
import java.io.File
import javax.inject.Inject
class FileProviderImpl @Inject constructor(
@ -18,6 +20,25 @@ class FileProviderImpl @Inject constructor(
) : FileProvider {
override fun getFileFromBook(book: Book): Result<CachedFile> = runCatching {
if (book.filePath.isBlank()) {
throw NoSuchElementException("Book file path is empty.")
}
val plainFile = File(book.filePath)
if (plainFile.exists() && plainFile.isFile) {
return@runCatching CachedFileCompat.fromUri(
application,
Uri.fromFile(plainFile),
builder = CachedFileCompat.build(
name = plainFile.name,
path = plainFile.absolutePath,
size = plainFile.length(),
lastModified = plainFile.lastModified(),
isDirectory = false
)
)
}
application.contentResolver.persistedUriPermissions.forEach { storage ->
val storageFile = CachedFileCompat.fromUri(
application,

View file

@ -11,7 +11,20 @@ import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book
interface CacheRepository {
suspend fun cacheBook(book: Book): Result<Unit>
/**
* Ensures the ebook file for [book] is available locally.
*
* @return the local database id of the book (existing or newly inserted).
*/
suspend fun cacheBook(book: Book): Result<Int>
/**
* Deletes the cached file(s) for the book identified by [remoteId].
*/
suspend fun deleteCache(remoteId: String): Result<Unit>
/**
* Observes the cache status for the book identified by [remoteId].
*/
fun observeCacheStatus(remoteId: String): Flow<CacheStatus>
}

View file

@ -15,5 +15,8 @@ class CacheBookUseCase
constructor(
private val repository: CacheRepository,
) {
suspend operator fun invoke(book: Book): Result<Unit> = repository.cacheBook(book)
/**
* Ensures the ebook file for [book] is cached locally and returns the local book id.
*/
suspend operator fun invoke(book: Book): Result<Int> = repository.cacheBook(book)
}

View file

@ -0,0 +1,14 @@
/*
* 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.bookshelf.presentation.bookshelf
import androidx.compose.runtime.Immutable
@Immutable
sealed class RemoteLibraryEffect {
data class OnNavigateToReader(val bookId: Int) : RemoteLibraryEffect()
}

View file

@ -9,13 +9,16 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.dueattendant149.bookshelf.data.settings.ServerSettings
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
import org.dueattendant149.bookshelf.domain.model.library.Book
@ -35,6 +38,7 @@ data class RemoteLibraryState(
val error: String? = null,
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
val offlineOnly: Boolean = false,
val cachingBookId: String? = null,
)
@HiltViewModel
@ -52,6 +56,9 @@ class RemoteLibraryModel
val state = _state.asStateFlow()
private var cacheStatusJob: Job? = null
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
val effects = _effects.asSharedFlow()
init {
loadLibraries()
}
@ -96,7 +103,9 @@ class RemoteLibraryModel
fun cacheBook(book: Book) {
viewModelScope.launch {
_state.update { it.copy(cachingBookId = book.remoteId) }
val result = cacheBookUseCase(book)
_state.update { it.copy(cachingBookId = null) }
if (result.isFailure) {
_state.update {
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
@ -121,6 +130,26 @@ class RemoteLibraryModel
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
}
fun openBook(book: Book) {
if (!book.hasEbook || book.remoteId.isBlank()) return
viewModelScope.launch {
_state.update { it.copy(cachingBookId = book.remoteId) }
val result = withContext(Dispatchers.IO) {
cacheBookUseCase(book)
}
_state.update { it.copy(cachingBookId = null) }
result
.onSuccess { bookId ->
_effects.emit(RemoteLibraryEffect.OnNavigateToReader(bookId))
}
.onFailure { error ->
_state.update { it.copy(error = error.message ?: "Could not open book") }
}
}
}
private fun observeCacheStatuses() {
cacheStatusJob?.cancel()
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }

View file

@ -8,10 +8,15 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.hilt.navigation.compose.hiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.parcelize.Parcelize
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
import org.dueattendant149.bookshelf.presentation.navigator.Screen
import org.dueattendant149.bookshelf.presentation.reader.ReaderScreen
import org.dueattendant149.bookshelf.ui.bookshelf.RemoteLibraryContent
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
@Parcelize
object RemoteLibraryScreen : Screen, Parcelable {
@ -19,6 +24,19 @@ object RemoteLibraryScreen : Screen, Parcelable {
@Composable
override fun Content() {
val model = hiltViewModel<RemoteLibraryModel>()
val navigator = LocalNavigator.current
LaunchedEffect(model.effects, navigator) {
model.effects.collectLatest { effect ->
when (effect) {
is RemoteLibraryEffect.OnNavigateToReader -> {
HistoryScreen.insertHistoryChannel.trySend(effect.bookId)
navigator.push(ReaderScreen(effect.bookId))
}
}
}
}
RemoteLibraryContent(model = model)
}
}

View file

@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Download
import androidx.compose.material.pullrefresh.PullRefreshIndicator
import androidx.compose.material.pullrefresh.pullRefresh
import androidx.compose.material.pullrefresh.rememberPullRefreshState
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
@ -40,6 +41,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.rememberVectorPainter
@ -63,6 +65,16 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
onRefresh = model::loadLibraries
)
val visibleBooks = remember(state.books, state.offlineOnly, state.cacheStatuses) {
if (!state.offlineOnly) state.books
else state.books.filter { book ->
val status = state.cacheStatuses[book.remoteId]
status?.state == CacheState.COMPLETED ||
status?.state == CacheState.DOWNLOADING ||
status?.state == CacheState.PENDING
}
}
Scaffold(
topBar = {
TopAppBar(
@ -155,14 +167,17 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
}
}
items(state.books.size) { index ->
val book = state.books[index]
items(visibleBooks.size) { index ->
val book = visibleBooks[index]
val cacheStatus = state.cacheStatuses[book.remoteId]
val isCaching = state.cachingBookId == book.remoteId
BookListItem(
book = book,
cacheStatus = cacheStatus,
isCaching = isCaching,
onDownload = { model.cacheBook(book) },
onDelete = { model.deleteCache(book) }
onDelete = { model.deleteCache(book) },
onRead = if (book.hasEbook) ({ model.openBook(book) }) else null
)
}
@ -215,8 +230,10 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
private fun BookListItem(
book: Book,
cacheStatus: CacheStatus?,
isCaching: Boolean,
onDownload: () -> Unit,
onDelete: () -> Unit,
onRead: (() -> Unit)?,
) {
val cacheState = cacheStatus?.state ?: CacheState.NONE
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
@ -278,6 +295,23 @@ private fun BookListItem(
.padding(horizontal = 16.dp, vertical = 8.dp),
)
}
onRead?.let { read ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 12.dp),
horizontalArrangement = Arrangement.End
) {
Button(
onClick = read,
enabled = !isCaching
) {
Text(stringResource(R.string.read))
}
}
}
}
}
}

View file

@ -98,6 +98,7 @@
<string name="web_search">Web search</string>
<string name="share">Share</string>
<string name="ok">OK</string>
<string name="read">Read</string>
<string name="change_path">Change path</string>
<string name="add_folder">Add folder</string>
<string name="edit">Edit</string>