From ed3afc1f166479aafc9b9fd94b23dd724937ef70 Mon Sep 17 00:00:00 2001 From: Atte149 Date: Tue, 16 Jun 2026 11:40:52 +0300 Subject: [PATCH] Merge feature/settings-validation (server validation, cache/TTS scaffold) into feature/ui-polish --- .../bookshelf/data/di/AppModule.kt | 5 + .../bookshelf/data/di/RepositoryModule.kt | 1 + .../data/local/room/CachedFileDao.kt | 12 +- .../bookshelfapi/BookshelfApiService.kt | 4 + .../data/repository/CacheRepositoryImpl.kt | 231 ++++++++++++++++++ .../bookshelf/data/settings/ServerSettings.kt | 62 ++++- .../data/worker/CacheDownloadWorker.kt | 194 +++++++++++++++ .../domain/model/cache/CacheStatus.kt | 29 +++ .../domain/repository/CacheRepository.kt | 17 ++ .../domain/use_case/cache/CacheBookUseCase.kt | 19 ++ .../use_case/cache/DeleteCacheUseCase.kt | 18 ++ .../use_case/cache/GetCacheStatusUseCase.kt | 20 ++ .../use_case/remote/CheckHealthUseCase.kt | 39 +++ .../bookshelf/domain/util/UriUtils.kt | 77 +++++- .../bookshelf/RemoteLibraryModel.kt | 63 +++++ .../settings/BookshelfSettingsModel.kt | 62 +++++ .../bookshelf/BookshelfSettingsContent.kt | 45 +++- app/src/main/res/values/strings.xml | 22 ++ 18 files changed, 896 insertions(+), 24 deletions(-) create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/model/cache/CacheStatus.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/repository/CacheRepository.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/DeleteCacheUseCase.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/GetCacheStatusUseCase.kt create mode 100644 app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/CheckHealthUseCase.kt diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt index 3103fcb0..6e23b992 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/AppModule.kt @@ -8,6 +8,7 @@ package org.dueattendant149.bookshelf.data.di import android.app.Application import androidx.room.Room +import androidx.work.WorkManager import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -46,6 +47,10 @@ object AppModule { .build() } + @Provides + @Singleton + fun provideWorkManager(app: Application): WorkManager = WorkManager.getInstance(app) + @Provides @Singleton fun provideBookDatabase(app: Application): BookDatabase { diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt index 83db147b..45b69adb 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/di/RepositoryModule.kt @@ -29,6 +29,7 @@ import org.dueattendant149.bookshelf.data.parser.file.FileParserImpl import org.dueattendant149.bookshelf.data.parser.text.TextParser import org.dueattendant149.bookshelf.data.parser.text.TextParserImpl import org.dueattendant149.bookshelf.data.repository.BookRepositoryImpl +import org.dueattendant149.bookshelf.data.repository.CacheRepositoryImpl import org.dueattendant149.bookshelf.data.repository.CategoryRepositoryImpl import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/CachedFileDao.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/CachedFileDao.kt index f24e165f..394ec3cf 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/CachedFileDao.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/local/room/CachedFileDao.kt @@ -12,16 +12,26 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Update +import kotlinx.coroutines.flow.Flow import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity @Dao interface CachedFileDao { @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(file: CachedFileEntity) + suspend fun insert(file: CachedFileEntity): Long @Query("SELECT * FROM cached_file WHERE bookId = :bookId AND type = :type") suspend fun getByBookIdAndType(bookId: Int, type: String): List + @Query("SELECT * FROM cached_file WHERE bookId = :bookId AND type = :type LIMIT 1") + suspend fun getByBookIdAndTypeSingle(bookId: Int, type: String): CachedFileEntity? + + @Query("SELECT * FROM cached_file WHERE bookId = :bookId") + suspend fun getByBookId(bookId: Int): List + + @Query("SELECT * FROM cached_file WHERE bookId = :bookId") + fun observeByBookId(bookId: Int): Flow> + @Query("SELECT * FROM cached_file WHERE status = :status") suspend fun getByStatus(status: String): List diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt index efd1bce7..2b10badb 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/remote/bookshelfapi/BookshelfApiService.kt @@ -31,6 +31,10 @@ import retrofit2.http.Streaming */ interface BookshelfApiService { + // Health + @GET("health") + suspend fun health(): Response + // Libraries / books @GET("api/v1/books/libraries") suspend fun getLibraries(): Response> diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt new file mode 100644 index 00000000..96e20511 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/repository/CacheRepositoryImpl.kt @@ -0,0 +1,231 @@ +/* + * 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.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 kotlinx.coroutines.Dispatchers +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.room.BookDatabase +import org.dueattendant149.bookshelf.data.mapper.book.BookMapper +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 +import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.domain.repository.CacheRepository +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CacheRepositoryImpl + @Inject + constructor( + private val database: BookDatabase, + private val bookMapper: BookMapper, + private val serverSettings: ServerSettings, + private val workManager: WorkManager, + private val application: Application, + ) : CacheRepository { + override suspend fun cacheBook(book: Book): Result = runCatching { + withContext(Dispatchers.IO) { + val localBook = findOrInsertBook(book) + val bookId = localBook.id + val absUrl = serverSettings.getAbsUrl() ?: error("Audiobookshelf URL not configured") + + if (book.coverUrl.isNotBlank()) { + createCacheRecord(bookId, "cover", book.coverUrl) + } + if (book.hasEbook) { + createCacheRecord( + bookId, + "ebook", + "$absUrl/api/items/${book.remoteId}/download", + ) + } + if (book.hasAudio) { + createCacheRecord( + bookId, + "audio", + "$absUrl/api/items/${book.remoteId}/download", + ) + } + + 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 request = + OneTimeWorkRequestBuilder() + .setInputData(inputData) + .setConstraints( + Constraints + .Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build(), + ) + .build() + + workManager.enqueueUniqueWork( + CacheDownloadWorker.workName(bookId), + ExistingWorkPolicy.KEEP, + request, + ) + } + } + + override suspend fun deleteCache(remoteId: String): Result = 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)) + } + } + + override fun observeCacheStatus(remoteId: String): Flow = + flow { + val bookId = database.bookDao.findBookByRemoteId(remoteId)?.id + if (bookId == null) { + emit( + CacheStatus( + remoteId = remoteId, + state = CacheState.NONE, + progress = 0f, + cachedFiles = 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.toCacheStatus(remoteId: String): CacheStatus { + if (isEmpty()) { + return CacheStatus( + remoteId = remoteId, + state = CacheState.NONE, + progress = 0f, + cachedFiles = emptyList(), + ) + } + + val files = + map { + CachedFile( + type = it.type, + status = it.status, + progress = it.progress, + 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 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, + ) + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt index d809090d..05ed56a8 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/settings/ServerSettings.kt @@ -8,6 +8,8 @@ package org.dueattendant149.bookshelf.data.settings import androidx.datastore.preferences.core.stringPreferencesKey import org.dueattendant149.bookshelf.data.local.data_store.DataStore +import org.dueattendant149.bookshelf.domain.util.isValidUri +import org.dueattendant149.bookshelf.domain.util.normalizeUri import javax.inject.Inject import javax.inject.Singleton @@ -21,23 +23,63 @@ class ServerSettings private val absUrlKey = stringPreferencesKey("abs_url") private val absTokenKey = stringPreferencesKey("abs_token") - suspend fun getBookshelfUrl(): String? = dataStore.getNullableData(bookshelfUrlKey) - suspend fun setBookshelfUrl(value: String) = dataStore.putData(bookshelfUrlKey, value) + suspend fun getBookshelfUrl(): String? = + dataStore.getNullableData(bookshelfUrlKey)?.normalizeIfValid() - suspend fun getAbsUrl(): String? = dataStore.getNullableData(absUrlKey) - suspend fun setAbsUrl(value: String) = dataStore.putData(absUrlKey, value) + suspend fun setBookshelfUrl(value: String) { + dataStore.putData(bookshelfUrlKey, value.normalizeIfValid() ?: value.trim()) + } - suspend fun getAbsToken(): String? = dataStore.getNullableData(absTokenKey) - suspend fun setAbsToken(value: String) = dataStore.putData(absTokenKey, value) + suspend fun getAbsUrl(): String? = + dataStore.getNullableData(absUrlKey)?.normalizeIfValid() - suspend fun hasCredentials(): Boolean = - !getBookshelfUrl().isNullOrBlank() && - !getAbsUrl().isNullOrBlank() && - !getAbsToken().isNullOrBlank() + suspend fun setAbsUrl(value: String) { + dataStore.putData(absUrlKey, value.normalizeIfValid() ?: value.trim()) + } + + suspend fun getAbsToken(): String? = dataStore.getNullableData(absTokenKey)?.trim() + suspend fun setAbsToken(value: String) = dataStore.putData(absTokenKey, value.trim()) + + suspend fun hasCredentials(): Boolean = getMissingCredentialsError() == null + + /** + * Returns a user-facing error message if any required credential is missing or invalid, + * or null when all credentials are present. + */ + suspend fun getMissingCredentialsError(): String? { + val bookshelfUrl = dataStore.getNullableData(bookshelfUrlKey) + if (bookshelfUrl.isNullOrBlank()) { + return "Bookshelf API URL is missing" + } + if (!bookshelfUrl.isValidUri()) { + return "Bookshelf API URL is invalid: $bookshelfUrl" + } + if (dataStore.getNullableData(absUrlKey).isNullOrBlank()) { + return "Audiobookshelf URL is missing" + } + if (dataStore.getNullableData(absTokenKey).isNullOrBlank()) { + return "ABS token is missing" + } + return null + } + + suspend fun validateBookshelfUrl(): Result { + val url = dataStore.getNullableData(bookshelfUrlKey)?.trim() + return when { + url.isNullOrBlank() -> Result.failure(IllegalArgumentException("Bookshelf URL is empty")) + !url.isValidUri() -> Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $url")) + else -> Result.success(url.normalizeUri()) + } + } suspend fun clear() { setBookshelfUrl("") setAbsUrl("") setAbsToken("") } + + private fun String.normalizeIfValid(): String? { + val trimmed = trim() + return if (trimmed.isValidUri()) trimmed.normalizeUri() else null + } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt new file mode 100644 index 00000000..03fd76e7 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/data/worker/CacheDownloadWorker.kt @@ -0,0 +1,194 @@ +/* + * 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.data.worker + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent +import okhttp3.ResponseBody +import org.dueattendant149.bookshelf.data.local.dto.CachedFileEntity +import org.dueattendant149.bookshelf.data.local.room.BookDatabase +import org.dueattendant149.bookshelf.data.remote.audiobookshelf.AudiobookshelfApiClientFactory +import org.dueattendant149.bookshelf.data.settings.ServerSettings +import retrofit2.Response +import java.io.File +import java.io.IOException + +class CacheDownloadWorker( + context: Context, + params: WorkerParameters, +) : CoroutineWorker(context, params) { + companion object { + const val KEY_BOOK_ID = "book_id" + const val KEY_REMOTE_ID = "remote_id" + const val KEY_TITLE = "title" + const val KEY_MEDIA_TYPE = "media_type" + const val KEY_HAS_AUDIO = "has_audio" + const val KEY_HAS_EBOOK = "has_ebook" + const val KEY_COVER_URL = "cover_url" + + fun workName(bookId: Int) = "cache-download-$bookId" + } + + private val entryPoint: CacheWorkerEntryPoint by lazy { + EntryPointAccessors.fromApplication(applicationContext, CacheWorkerEntryPoint::class.java) + } + + override suspend fun doWork(): Result { + val bookId = inputData.getInt(KEY_BOOK_ID, -1) + val remoteId = inputData.getString(KEY_REMOTE_ID) ?: return Result.failure() + val title = inputData.getString(KEY_TITLE) ?: remoteId + val hasAudio = inputData.getBoolean(KEY_HAS_AUDIO, false) + val hasEbook = inputData.getBoolean(KEY_HAS_EBOOK, false) + val coverUrl = inputData.getString(KEY_COVER_URL) ?: "" + + if (bookId < 0) { + return Result.failure() + } + + val database = entryPoint.database() + val serverSettings = entryPoint.serverSettings() + val clientFactory = entryPoint.audiobookshelfFactory() + + val absUrl = serverSettings.getAbsUrl() + val token = serverSettings.getAbsToken() + if (absUrl.isNullOrBlank() || token.isNullOrBlank()) { + Log.e(TAG, "ABS credentials not configured") + return Result.failure() + } + + val client = clientFactory.provideClient(absUrl, token) + if (client == null) { + Log.e(TAG, "Failed to create ABS client") + return Result.failure() + } + + val bookDir = File(applicationContext.filesDir, "bookshelf-cache/$bookId").apply { mkdirs() } + + return try { + if (coverUrl.isNotBlank()) { + downloadFile( + database = database, + bookId = bookId, + type = "cover", + remoteUrl = coverUrl, + localFile = File(bookDir, "cover"), + ) { client.getCover(remoteId) } + } + + if (hasEbook) { + downloadFile( + database = database, + bookId = bookId, + type = "ebook", + remoteUrl = "$absUrl/api/items/$remoteId/download", + localFile = File(bookDir, "ebook"), + ) { client.downloadBook(remoteId) } + } + + if (hasAudio) { + downloadFile( + database = database, + bookId = bookId, + type = "audio", + remoteUrl = "$absUrl/api/items/$remoteId/download", + localFile = File(bookDir, "audio"), + ) { client.downloadBook(remoteId) } + } + + Result.success() + } catch (e: Exception) { + Log.e(TAG, "Cache download failed for book $bookId ($title)", e) + Result.retry() + } + } + + private suspend fun downloadFile( + database: BookDatabase, + bookId: Int, + type: String, + remoteUrl: String, + localFile: File, + download: suspend () -> Response, + ) { + val record = getOrCreateRecord(database, bookId, type, remoteUrl) + + if (record.status == "completed" && record.localPath != null && File(record.localPath).exists()) { + return + } + + database.cachedFileDao.update(record.copy(status = "downloading", progress = 0f)) + + try { + val response = download() + if (!response.isSuccessful) { + throw IOException("Download failed: ${response.code()}") + } + + val body = response.body() ?: throw IOException("Empty response body") + val totalBytes = body.contentLength().takeIf { it > 0 } ?: -1L + + body.byteStream().use { input -> + localFile.outputStream().use { output -> + val buffer = ByteArray(8192) + var read: Int + var totalRead = 0L + while (input.read(buffer).also { read = it } != -1) { + output.write(buffer, 0, read) + totalRead += read + if (totalBytes > 0) { + val progress = totalRead / totalBytes.toFloat() + database.cachedFileDao.update( + record.copy(status = "downloading", progress = progress), + ) + } + } + } + } + + database.cachedFileDao.update( + record.copy( + status = "completed", + progress = 1f, + localPath = localFile.absolutePath, + ), + ) + } catch (e: Exception) { + database.cachedFileDao.update(record.copy(status = "failed", progress = 0f)) + throw e + } + } + + private suspend fun getOrCreateRecord( + database: BookDatabase, + bookId: Int, + type: String, + remoteUrl: String, + ): CachedFileEntity { + return database.cachedFileDao.getByBookIdAndTypeSingle(bookId, type) + ?: CachedFileEntity( + bookId = bookId, + type = type, + remoteUrl = remoteUrl, + ).also { database.cachedFileDao.insert(it) } + } + + @EntryPoint + @InstallIn(SingletonComponent::class) + interface CacheWorkerEntryPoint { + fun database(): BookDatabase + fun serverSettings(): ServerSettings + fun audiobookshelfFactory(): AudiobookshelfApiClientFactory + } +} + +private const val TAG = "CacheDownloadWorker" diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/model/cache/CacheStatus.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/cache/CacheStatus.kt new file mode 100644 index 00000000..303c0487 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/model/cache/CacheStatus.kt @@ -0,0 +1,29 @@ +/* + * 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.domain.model.cache + +data class CacheStatus( + val remoteId: String, + val state: CacheState, + val progress: Float, + val cachedFiles: List, +) + +enum class CacheState { + NONE, + PENDING, + DOWNLOADING, + COMPLETED, + FAILED, +} + +data class CachedFile( + val type: String, + val status: String, + val progress: Float, + val localPath: String?, +) diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/CacheRepository.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/CacheRepository.kt new file mode 100644 index 00000000..fe985f86 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/repository/CacheRepository.kt @@ -0,0 +1,17 @@ +/* + * 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.domain.repository + +import kotlinx.coroutines.flow.Flow +import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus +import org.dueattendant149.bookshelf.domain.model.library.Book + +interface CacheRepository { + suspend fun cacheBook(book: Book): Result + suspend fun deleteCache(remoteId: String): Result + fun observeCacheStatus(remoteId: String): Flow +} diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt new file mode 100644 index 00000000..111fe9e5 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/CacheBookUseCase.kt @@ -0,0 +1,19 @@ +/* + * 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.domain.use_case.cache + +import org.dueattendant149.bookshelf.domain.model.library.Book +import org.dueattendant149.bookshelf.domain.repository.CacheRepository +import javax.inject.Inject + +class CacheBookUseCase + @Inject + constructor( + private val repository: CacheRepository, + ) { + suspend operator fun invoke(book: Book): Result = repository.cacheBook(book) + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/DeleteCacheUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/DeleteCacheUseCase.kt new file mode 100644 index 00000000..6423ae4a --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/DeleteCacheUseCase.kt @@ -0,0 +1,18 @@ +/* + * 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.domain.use_case.cache + +import org.dueattendant149.bookshelf.domain.repository.CacheRepository +import javax.inject.Inject + +class DeleteCacheUseCase + @Inject + constructor( + private val repository: CacheRepository, + ) { + suspend operator fun invoke(remoteId: String): Result = repository.deleteCache(remoteId) + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/GetCacheStatusUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/GetCacheStatusUseCase.kt new file mode 100644 index 00000000..f1abebf5 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/cache/GetCacheStatusUseCase.kt @@ -0,0 +1,20 @@ +/* + * 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.domain.use_case.cache + +import kotlinx.coroutines.flow.Flow +import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus +import org.dueattendant149.bookshelf.domain.repository.CacheRepository +import javax.inject.Inject + +class GetCacheStatusUseCase + @Inject + constructor( + private val repository: CacheRepository, + ) { + operator fun invoke(remoteId: String): Flow = repository.observeCacheStatus(remoteId) + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/CheckHealthUseCase.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/CheckHealthUseCase.kt new file mode 100644 index 00000000..d363c165 --- /dev/null +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/use_case/remote/CheckHealthUseCase.kt @@ -0,0 +1,39 @@ +/* + * 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.domain.use_case.remote + +import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory +import org.dueattendant149.bookshelf.domain.util.isValidUri +import org.dueattendant149.bookshelf.domain.util.normalizeUri +import javax.inject.Inject + +class CheckHealthUseCase + @Inject + constructor( + private val clientFactory: BookshelfApiClientFactory, + ) { + suspend operator fun invoke(url: String): Result { + val trimmed = url.trim() + if (trimmed.isBlank()) { + return Result.failure(IllegalArgumentException("Bookshelf URL is empty")) + } + if (!trimmed.isValidUri()) { + return Result.failure(IllegalArgumentException("Bookshelf URL is invalid: $trimmed")) + } + + val normalized = trimmed.normalizeUri() + val client = clientFactory.provideClient(normalized) + ?: return Result.failure(IllegalStateException("Could not create API client for $normalized")) + + return runCatching { + val response = client.health() + if (!response.isSuccessful) { + throw IllegalStateException("Server returned HTTP ${response.code()}") + } + } + } + } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt b/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt index 3987268d..b81b0c9c 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/domain/util/UriUtils.kt @@ -1,16 +1,71 @@ package org.dueattendant149.bookshelf.domain.util +import android.net.Uri + +private val URL_SCHEME_REGEX = Regex("^[hH][tT][tT][pP][sS]?://") + /** - * Ensures the URL ends with a trailing slash so Retrofit can append relative paths. - * Also adds a default scheme if missing. + * 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.fixUriScheme(): String { - var url = this.trim() - if (!url.startsWith("http://") && !url.startsWith("https://")) { - url = "https://$url" - } - if (!url.endsWith("/")) { - url = "$url/" - } - return url +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 = matches(URL_SCHEME_REGEX) + +/** + * Adds https:// if the string has no http/https scheme. + */ +fun String.ensureUriScheme(): String { + val trimmed = trim() + return if (trimmed.hasUriScheme()) trimmed else "https://$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/" } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt index 48fe61c3..23d53e0f 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/bookshelf/RemoteLibraryModel.kt @@ -9,13 +9,20 @@ package org.dueattendant149.bookshelf.presentation.bookshelf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow 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 org.dueattendant149.bookshelf.data.settings.ServerSettings +import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus import org.dueattendant149.bookshelf.domain.model.library.Book import org.dueattendant149.bookshelf.domain.model.remote.RemoteLibrary +import org.dueattendant149.bookshelf.domain.use_case.cache.CacheBookUseCase +import org.dueattendant149.bookshelf.domain.use_case.cache.DeleteCacheUseCase +import org.dueattendant149.bookshelf.domain.use_case.cache.GetCacheStatusUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchBooksUseCase import org.dueattendant149.bookshelf.domain.use_case.remote.FetchLibrariesUseCase import javax.inject.Inject @@ -26,6 +33,8 @@ data class RemoteLibraryState( val selectedLibrary: RemoteLibrary? = null, val isLoading: Boolean = false, val error: String? = null, + val cacheStatuses: Map = emptyMap(), + val offlineOnly: Boolean = false, ) @HiltViewModel @@ -34,10 +43,14 @@ class RemoteLibraryModel constructor( private val fetchLibrariesUseCase: FetchLibrariesUseCase, private val fetchBooksUseCase: FetchBooksUseCase, + private val cacheBookUseCase: CacheBookUseCase, + private val deleteCacheUseCase: DeleteCacheUseCase, + private val getCacheStatusUseCase: GetCacheStatusUseCase, private val serverSettings: ServerSettings, ) : ViewModel() { private val _state = MutableStateFlow(RemoteLibraryState()) val state = _state.asStateFlow() + private var cacheStatusJob: Job? = null init { loadLibraries() @@ -77,6 +90,56 @@ class RemoteLibraryModel error = result.exceptionOrNull()?.message, ) } + observeCacheStatuses() } } + + fun cacheBook(book: Book) { + viewModelScope.launch { + val result = cacheBookUseCase(book) + if (result.isFailure) { + _state.update { + it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed") + } + } + observeCacheStatuses() + } + } + + fun deleteCache(book: Book) { + viewModelScope.launch { + val result = deleteCacheUseCase(book.remoteId) + if (result.isFailure) { + _state.update { + it.copy(error = result.exceptionOrNull()?.message ?: "Delete cache failed") + } + } + } + } + + fun toggleOfflineOnly() { + _state.update { it.copy(offlineOnly = !it.offlineOnly) } + } + + private fun observeCacheStatuses() { + cacheStatusJob?.cancel() + val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() } + if (remoteIds.isEmpty()) { + _state.update { it.copy(cacheStatuses = emptyMap()) } + return + } + + cacheStatusJob = + viewModelScope.launch { + combine( + remoteIds.map { remoteId -> + getCacheStatusUseCase(remoteId) + }, + ) { statuses -> + statuses.toList().associateBy { it.remoteId } + }.collect { statuses -> + _state.update { it.copy(cacheStatuses = statuses) } + } + } + } } diff --git a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt index 508329f9..ccee3aba 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/presentation/settings/BookshelfSettingsModel.kt @@ -15,7 +15,9 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.dueattendant149.bookshelf.data.settings.ServerSettings 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.FetchLibrariesUseCase +import org.dueattendant149.bookshelf.domain.util.deriveAbsUrl import javax.inject.Inject data class BookshelfSettingsState( @@ -24,6 +26,9 @@ data class BookshelfSettingsState( val absToken: String = "", val libraries: List = emptyList(), val isLoading: Boolean = false, + val isCheckingConnection: Boolean = false, + val connectionError: String? = null, + val connectionSuccess: Boolean = false, val error: String? = null, val librariesSuccess: Boolean = false, ) @@ -34,6 +39,7 @@ class BookshelfSettingsModel constructor( private val serverSettings: ServerSettings, private val fetchLibrariesUseCase: FetchLibrariesUseCase, + private val checkHealthUseCase: CheckHealthUseCase, ) : ViewModel() { private val _state = MutableStateFlow(BookshelfSettingsState()) val state = _state.asStateFlow() @@ -58,6 +64,8 @@ class BookshelfSettingsModel it.copy( bookshelfUrl = value, librariesSuccess = false, + connectionSuccess = false, + connectionError = null, error = null, ) } @@ -66,6 +74,8 @@ class BookshelfSettingsModel it.copy( absUrl = value, librariesSuccess = false, + connectionSuccess = false, + connectionError = null, error = null, ) } @@ -74,10 +84,27 @@ class BookshelfSettingsModel it.copy( absToken = value, librariesSuccess = false, + connectionSuccess = false, + connectionError = null, error = null, ) } + fun deriveAbsUrlFromBookshelf() { + viewModelScope.launch { + val derived = _state.value.bookshelfUrl.deriveAbsUrl() + if (derived != null) { + _state.update { + it.copy( + absUrl = derived, + librariesSuccess = false, + error = null, + ) + } + } + } + } + fun save() { viewModelScope.launch { val current = _state.value @@ -87,6 +114,29 @@ class BookshelfSettingsModel } } + fun checkConnection() { + viewModelScope.launch { + _state.update { + it.copy( + isCheckingConnection = true, + connectionError = null, + connectionSuccess = false, + ) + } + save() + + val url = _state.value.bookshelfUrl + val result = checkHealthUseCase(url) + _state.update { + it.copy( + isCheckingConnection = false, + connectionSuccess = result.isSuccess, + connectionError = result.exceptionOrNull()?.message, + ) + } + } + } + fun testLibraries() { viewModelScope.launch { _state.update { @@ -98,6 +148,18 @@ class BookshelfSettingsModel } save() + val missingError = serverSettings.getMissingCredentialsError() + if (missingError != null) { + _state.update { + it.copy( + isLoading = false, + libraries = emptyList(), + error = missingError, + ) + } + return@launch + } + val result = fetchLibrariesUseCase() val libraries = result.getOrDefault(emptyList()) _state.update { diff --git a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt index 6931d91f..d3dc34fb 100644 --- a/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt +++ b/app/src/main/java/org/dueattendant149/bookshelf/ui/settings/bookshelf/BookshelfSettingsContent.kt @@ -104,14 +104,39 @@ fun BookshelfSettingsContent( ) { Button( onClick = model::save, - enabled = !state.isLoading, + enabled = !state.isLoading && !state.isCheckingConnection, modifier = Modifier.weight(1f) ) { Text(stringResource(R.string.save_button)) } + Button( + onClick = model::checkConnection, + enabled = !state.isLoading && !state.isCheckingConnection, + modifier = Modifier.weight(1f) + ) { + if (state.isCheckingConnection) { + CircularProgressIndicator() + } else { + Text(stringResource(R.string.check_connection_button)) + } + } + } + } + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = model::deriveAbsUrlFromBookshelf, + enabled = state.bookshelfUrl.isNotBlank() && !state.isLoading, + modifier = Modifier.weight(1f) + ) { + Text(stringResource(R.string.derive_abs_url_button)) + } OutlinedButton( onClick = model::testLibraries, - enabled = !state.isLoading, + enabled = !state.isLoading && !state.isCheckingConnection, modifier = Modifier.weight(1f) ) { if (state.isLoading) { @@ -122,6 +147,22 @@ fun BookshelfSettingsContent( } } } + state.connectionSuccess.takeIf { it }?.let { + item { + Text( + text = stringResource(R.string.connection_successful), + color = MaterialTheme.colorScheme.primary, + ) + } + } + state.connectionError?.let { error -> + item { + Text( + text = error, + color = MaterialTheme.colorScheme.error, + ) + } + } state.librariesSuccess.takeIf { it }?.let { item { Row( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e41bb59f..73a879e0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -137,6 +137,18 @@ Bookshelf Server, ABS, TTS + + TTS + Engine + Voice + Book ID + Speed + Create job + Refresh jobs + Jobs + TTS jobs + TTS + Bookshelf server Bookshelf API URL @@ -146,7 +158,10 @@ ABS token API token from Audiobookshelf settings Save + Check connection + Derive ABS URL Fetch libraries + Connection successful Connected to %1$d libraries Libraries %1$s • %2$d items @@ -510,6 +525,13 @@ Add books Bookshelf Remote bookshelf + Offline only + Pending + Downloading + Cached + Failed + Download + Delete Arrow Reset start guide Open in web