Merge feature/ebook: open remote ebooks from RemoteLibrary
This commit is contained in:
commit
c8c1503d8c
12 changed files with 286 additions and 179 deletions
|
|
@ -68,8 +68,12 @@
|
||||||
- Phase 2: network layer (Retrofit, OkHttp, bookshelf-api + ABS clients) completed.
|
- 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 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 4: domain layer (ServerSettings, RemoteLibrary repository, use cases) completed.
|
||||||
- Phase 5: UI scaffold (server settings screen, RemoteLibrary tab) completed.
|
- Phase 5a: UI scaffold (server settings screen, RemoteLibrary tab) completed.
|
||||||
- Next: audio player (ExoPlayer + MediaSession), TTS integration, offline cache, reader integration for ebooks.
|
- 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
|
## WARNs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.domain.repository.BookRepository
|
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.CategoryRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
|
import org.dueattendant149.bookshelf.domain.repository.ColorPresetRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
|
import org.dueattendant149.bookshelf.domain.repository.FileSystemRepository
|
||||||
|
|
@ -96,6 +97,12 @@ abstract class RepositoryModule {
|
||||||
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
|
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
|
||||||
): RemoteLibraryRepository
|
): RemoteLibraryRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindCacheRepository(
|
||||||
|
cacheRepositoryImpl: CacheRepositoryImpl
|
||||||
|
): CacheRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindBookMapper(
|
abstract fun bindBookMapper(
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,11 @@ class CachedFile(
|
||||||
val isDirectory: Boolean get() = builder?.isDirectory ?: queryParams.isDirectory
|
val isDirectory: Boolean get() = builder?.isDirectory ?: queryParams.isDirectory
|
||||||
|
|
||||||
fun canAccess(): Boolean {
|
fun canAccess(): Boolean {
|
||||||
|
if (uri.scheme == "file") {
|
||||||
|
val file = File(uri.path ?: return false)
|
||||||
|
return file.exists() && file.canRead()
|
||||||
|
}
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
context.contentResolver.query(uri, null, null, null, null)?.let {
|
context.contentResolver.query(uri, null, null, null, null)?.let {
|
||||||
it.close()
|
it.close()
|
||||||
|
|
|
||||||
|
|
@ -7,24 +7,17 @@
|
||||||
package org.dueattendant149.bookshelf.data.repository
|
package org.dueattendant149.bookshelf.data.repository
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import androidx.work.Constraints
|
import android.webkit.URLUtil
|
||||||
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.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
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.CachedFileEntity
|
||||||
|
import org.dueattendant149.bookshelf.data.local.dto.EbookFileEntity
|
||||||
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
||||||
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
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.settings.ServerSettings
|
||||||
import org.dueattendant149.bookshelf.data.worker.CacheDownloadWorker
|
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
|
import org.dueattendant149.bookshelf.domain.model.cache.CacheState
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
|
import org.dueattendant149.bookshelf.domain.model.cache.CachedFile
|
||||||
|
|
@ -38,194 +31,159 @@ import javax.inject.Singleton
|
||||||
class CacheRepositoryImpl
|
class CacheRepositoryImpl
|
||||||
@Inject
|
@Inject
|
||||||
constructor(
|
constructor(
|
||||||
|
private val application: Application,
|
||||||
private val database: BookDatabase,
|
private val database: BookDatabase,
|
||||||
private val bookMapper: BookMapper,
|
private val bookMapper: BookMapper,
|
||||||
|
private val apiFactory: AudiobookshelfApiClientFactory,
|
||||||
private val serverSettings: ServerSettings,
|
private val serverSettings: ServerSettings,
|
||||||
private val workManager: WorkManager,
|
|
||||||
private val application: Application,
|
|
||||||
) : CacheRepository {
|
) : 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) {
|
withContext(Dispatchers.IO) {
|
||||||
val localBook = findOrInsertBook(book)
|
require(book.hasEbook) { "Book has no ebook." }
|
||||||
val bookId = localBook.id
|
require(book.remoteId.isNotBlank()) { "Book has no remote id." }
|
||||||
val absUrl = serverSettings.getAbsUrl() ?: error("Audiobookshelf URL not configured")
|
|
||||||
|
|
||||||
if (book.coverUrl.isNotBlank()) {
|
val existing = database.bookDao.findBookByRemoteId(book.remoteId)
|
||||||
createCacheRecord(bookId, "cover", book.coverUrl)
|
val cachedEbook = existing?.let { database.ebookFileDao.getByBookId(it.id) }
|
||||||
}
|
|
||||||
if (book.hasEbook) {
|
if (existing != null &&
|
||||||
createCacheRecord(
|
cachedEbook?.localPath != null &&
|
||||||
bookId,
|
File(cachedEbook.localPath).exists()
|
||||||
"ebook",
|
) {
|
||||||
"$absUrl/api/items/${book.remoteId}/download",
|
if (existing.filePath != cachedEbook.localPath) {
|
||||||
|
database.bookDao.updateBook(
|
||||||
|
bookMapper.toBookEntity(
|
||||||
|
bookMapper.toBook(existing).copy(filePath = cachedEbook.localPath)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (book.hasAudio) {
|
return@withContext existing.id
|
||||||
createCacheRecord(
|
|
||||||
bookId,
|
|
||||||
"audio",
|
|
||||||
"$absUrl/api/items/${book.remoteId}/download",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val inputData =
|
val url = serverSettings.getAbsUrl()
|
||||||
Data
|
val token = serverSettings.getAbsToken()
|
||||||
.Builder()
|
if (url.isNullOrBlank() || token.isNullOrBlank()) {
|
||||||
.putInt(CacheDownloadWorker.KEY_BOOK_ID, bookId)
|
throw IllegalStateException("Audiobookshelf server is not configured.")
|
||||||
.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 =
|
val client = apiFactory.provideClient(url, token)
|
||||||
OneTimeWorkRequestBuilder<CacheDownloadWorker>()
|
?: throw IllegalStateException("Could not create Audiobookshelf client.")
|
||||||
.setInputData(inputData)
|
|
||||||
.setConstraints(
|
|
||||||
Constraints
|
|
||||||
.Builder()
|
|
||||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
|
||||||
.build(),
|
|
||||||
)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
workManager.enqueueUniqueWork(
|
val response = client.downloadBook(book.remoteId)
|
||||||
CacheDownloadWorker.workName(bookId),
|
if (!response.isSuccessful) {
|
||||||
ExistingWorkPolicy.KEEP,
|
throw IllegalStateException("Download failed: ${response.code()}")
|
||||||
request,
|
}
|
||||||
|
|
||||||
|
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 {
|
override suspend fun deleteCache(remoteId: String): Result<Unit> = runCatching {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
val book = database.bookDao.findBookByRemoteId(remoteId) ?: return@withContext
|
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||||
val records = database.cachedFileDao.getByBookId(book.id)
|
?: throw IllegalStateException("Book not found")
|
||||||
records.forEach { record ->
|
val ebook = database.ebookFileDao.getByBookId(book.id)
|
||||||
record.localPath?.let { path ->
|
val cachedFiles = cachedFileDao.getByBookId(book.id)
|
||||||
File(path).delete()
|
|
||||||
}
|
ebook?.localPath?.let { File(it).delete() }
|
||||||
database.cachedFileDao.delete(record)
|
cachedFiles.mapNotNull { it.localPath }.forEach { File(it).delete() }
|
||||||
}
|
|
||||||
workManager.cancelUniqueWork(CacheDownloadWorker.workName(book.id))
|
database.ebookFileDao.deleteByBookId(book.id)
|
||||||
|
cachedFileDao.deleteByBookId(book.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> =
|
override fun observeCacheStatus(remoteId: String): Flow<CacheStatus> = flow {
|
||||||
flow {
|
val book = database.bookDao.findBookByRemoteId(remoteId)
|
||||||
val bookId = database.bookDao.findBookByRemoteId(remoteId)?.id
|
if (book == null) {
|
||||||
if (bookId == null) {
|
emit(CacheStatus(remoteId, CacheState.NONE, 0f, emptyList()))
|
||||||
emit(
|
|
||||||
CacheStatus(
|
|
||||||
remoteId = remoteId,
|
|
||||||
state = CacheState.NONE,
|
|
||||||
progress = 0f,
|
|
||||||
cachedFiles = emptyList(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return@flow
|
return@flow
|
||||||
}
|
}
|
||||||
|
|
||||||
database.cachedFileDao.observeByBookId(bookId).map { records ->
|
cachedFileDao.observeByBookId(book.id).collect { entities ->
|
||||||
records.toCacheStatus(remoteId)
|
val files = entities.map {
|
||||||
}.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 {
|
|
||||||
CachedFile(
|
CachedFile(
|
||||||
type = it.type,
|
type = it.type,
|
||||||
status = it.status,
|
status = it.status,
|
||||||
progress = it.progress,
|
progress = it.progress,
|
||||||
localPath = it.localPath,
|
localPath = it.localPath
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val state = when {
|
||||||
val state =
|
entities.isEmpty() -> CacheState.NONE
|
||||||
when {
|
entities.all { it.status == "completed" } -> CacheState.COMPLETED
|
||||||
files.any { it.status == "failed" } -> CacheState.FAILED
|
entities.any { it.status == "failed" } -> CacheState.FAILED
|
||||||
files.any { it.status == "downloading" } -> CacheState.DOWNLOADING
|
entities.any { it.status == "downloading" } -> CacheState.DOWNLOADING
|
||||||
files.all { it.status == "completed" } -> CacheState.COMPLETED
|
entities.any { it.status == "pending" } -> CacheState.PENDING
|
||||||
else -> 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 =
|
private fun guessFileName(response: retrofit2.Response<okhttp3.ResponseBody>, remoteId: String): String {
|
||||||
when (state) {
|
val contentDisposition = response.headers()["Content-Disposition"]
|
||||||
CacheState.DOWNLOADING -> files.filter { it.status == "downloading" }.map { it.progress }.average().toFloat()
|
val mimeType = response.body()?.contentType()?.toString()
|
||||||
CacheState.COMPLETED -> 1f
|
val url = response.raw().request.url.toString()
|
||||||
else -> 0f
|
val name = URLUtil.guessFileName(url, contentDisposition, mimeType)
|
||||||
}
|
return if (name.contains(".")) name else "$remoteId.epub"
|
||||||
|
|
||||||
return CacheStatus(
|
|
||||||
remoteId = remoteId,
|
|
||||||
state = state,
|
|
||||||
progress = progress,
|
|
||||||
cachedFiles = files,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,12 @@
|
||||||
package org.dueattendant149.bookshelf.data.service
|
package org.dueattendant149.bookshelf.data.service
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import android.net.Uri
|
||||||
import org.dueattendant149.bookshelf.data.model.file.CachedFile
|
import org.dueattendant149.bookshelf.data.model.file.CachedFile
|
||||||
import org.dueattendant149.bookshelf.data.model.file.CachedFileCompat
|
import org.dueattendant149.bookshelf.data.model.file.CachedFileCompat
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
import org.dueattendant149.bookshelf.domain.service.FileProvider
|
import org.dueattendant149.bookshelf.domain.service.FileProvider
|
||||||
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class FileProviderImpl @Inject constructor(
|
class FileProviderImpl @Inject constructor(
|
||||||
|
|
@ -18,6 +20,25 @@ class FileProviderImpl @Inject constructor(
|
||||||
) : FileProvider {
|
) : FileProvider {
|
||||||
|
|
||||||
override fun getFileFromBook(book: Book): Result<CachedFile> = runCatching {
|
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 ->
|
application.contentResolver.persistedUriPermissions.forEach { storage ->
|
||||||
val storageFile = CachedFileCompat.fromUri(
|
val storageFile = CachedFileCompat.fromUri(
|
||||||
application,
|
application,
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,20 @@ import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
|
||||||
interface CacheRepository {
|
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>
|
suspend fun deleteCache(remoteId: String): Result<Unit>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observes the cache status for the book identified by [remoteId].
|
||||||
|
*/
|
||||||
fun observeCacheStatus(remoteId: String): Flow<CacheStatus>
|
fun observeCacheStatus(remoteId: String): Flow<CacheStatus>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,5 +15,8 @@ class CacheBookUseCase
|
||||||
constructor(
|
constructor(
|
||||||
private val repository: CacheRepository,
|
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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -9,13 +9,16 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.flowOf
|
|
||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
import org.dueattendant149.bookshelf.domain.model.cache.CacheStatus
|
||||||
import org.dueattendant149.bookshelf.domain.model.library.Book
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
|
@ -35,6 +38,7 @@ data class RemoteLibraryState(
|
||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
|
val cacheStatuses: Map<String, CacheStatus> = emptyMap(),
|
||||||
val offlineOnly: Boolean = false,
|
val offlineOnly: Boolean = false,
|
||||||
|
val cachingBookId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
|
|
@ -52,6 +56,9 @@ class RemoteLibraryModel
|
||||||
val state = _state.asStateFlow()
|
val state = _state.asStateFlow()
|
||||||
private var cacheStatusJob: Job? = null
|
private var cacheStatusJob: Job? = null
|
||||||
|
|
||||||
|
private val _effects = MutableSharedFlow<RemoteLibraryEffect>()
|
||||||
|
val effects = _effects.asSharedFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadLibraries()
|
loadLibraries()
|
||||||
}
|
}
|
||||||
|
|
@ -96,7 +103,9 @@ class RemoteLibraryModel
|
||||||
|
|
||||||
fun cacheBook(book: Book) {
|
fun cacheBook(book: Book) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(cachingBookId = book.remoteId) }
|
||||||
val result = cacheBookUseCase(book)
|
val result = cacheBookUseCase(book)
|
||||||
|
_state.update { it.copy(cachingBookId = null) }
|
||||||
if (result.isFailure) {
|
if (result.isFailure) {
|
||||||
_state.update {
|
_state.update {
|
||||||
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
|
it.copy(error = result.exceptionOrNull()?.message ?: "Cache failed")
|
||||||
|
|
@ -121,6 +130,26 @@ class RemoteLibraryModel
|
||||||
_state.update { it.copy(offlineOnly = !it.offlineOnly) }
|
_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() {
|
private fun observeCacheStatuses() {
|
||||||
cacheStatusJob?.cancel()
|
cacheStatusJob?.cancel()
|
||||||
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
|
val remoteIds = state.value.books.map { it.remoteId }.filter { it.isNotBlank() }
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,15 @@ package org.dueattendant149.bookshelf.presentation.bookshelf
|
||||||
|
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.navigator.Screen
|
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.bookshelf.RemoteLibraryContent
|
||||||
|
import org.dueattendant149.bookshelf.ui.navigator.LocalNavigator
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
object RemoteLibraryScreen : Screen, Parcelable {
|
object RemoteLibraryScreen : Screen, Parcelable {
|
||||||
|
|
@ -19,6 +24,19 @@ object RemoteLibraryScreen : Screen, Parcelable {
|
||||||
@Composable
|
@Composable
|
||||||
override fun Content() {
|
override fun Content() {
|
||||||
val model = hiltViewModel<RemoteLibraryModel>()
|
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)
|
RemoteLibraryContent(model = model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ import androidx.compose.material.icons.filled.Download
|
||||||
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
import androidx.compose.material.pullrefresh.PullRefreshIndicator
|
||||||
import androidx.compose.material.pullrefresh.pullRefresh
|
import androidx.compose.material.pullrefresh.pullRefresh
|
||||||
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
import androidx.compose.material.pullrefresh.rememberPullRefreshState
|
||||||
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
|
@ -40,6 +41,7 @@ import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||||
|
|
@ -63,6 +65,16 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||||
onRefresh = model::loadLibraries
|
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(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
|
@ -155,14 +167,17 @@ fun RemoteLibraryContent(model: RemoteLibraryModel) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
items(state.books.size) { index ->
|
items(visibleBooks.size) { index ->
|
||||||
val book = state.books[index]
|
val book = visibleBooks[index]
|
||||||
val cacheStatus = state.cacheStatuses[book.remoteId]
|
val cacheStatus = state.cacheStatuses[book.remoteId]
|
||||||
|
val isCaching = state.cachingBookId == book.remoteId
|
||||||
BookListItem(
|
BookListItem(
|
||||||
book = book,
|
book = book,
|
||||||
cacheStatus = cacheStatus,
|
cacheStatus = cacheStatus,
|
||||||
|
isCaching = isCaching,
|
||||||
onDownload = { model.cacheBook(book) },
|
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(
|
private fun BookListItem(
|
||||||
book: Book,
|
book: Book,
|
||||||
cacheStatus: CacheStatus?,
|
cacheStatus: CacheStatus?,
|
||||||
|
isCaching: Boolean,
|
||||||
onDownload: () -> Unit,
|
onDownload: () -> Unit,
|
||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
|
onRead: (() -> Unit)?,
|
||||||
) {
|
) {
|
||||||
val cacheState = cacheStatus?.state ?: CacheState.NONE
|
val cacheState = cacheStatus?.state ?: CacheState.NONE
|
||||||
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
|
val showDownload = cacheState == CacheState.NONE || cacheState == CacheState.FAILED
|
||||||
|
|
@ -278,6 +295,23 @@ private fun BookListItem(
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@
|
||||||
<string name="web_search">Web search</string>
|
<string name="web_search">Web search</string>
|
||||||
<string name="share">Share</string>
|
<string name="share">Share</string>
|
||||||
<string name="ok">OK</string>
|
<string name="ok">OK</string>
|
||||||
|
<string name="read">Read</string>
|
||||||
<string name="change_path">Change path</string>
|
<string name="change_path">Change path</string>
|
||||||
<string name="add_folder">Add folder</string>
|
<string name="add_folder">Add folder</string>
|
||||||
<string name="edit">Edit</string>
|
<string name="edit">Edit</string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue