Compare commits

...

18 commits

Author SHA1 Message Date
Atte149
8e94eb3850 docs(AGENTS): Phase 5 completed, Phase 6 in progress 2026-06-24 18:08:23 +03:00
Atte149
fb6403c4a9 feat(phase5): reader engine integration — Checkpoint model + ChapterDrawer
- Checkpoint: serializable model for reading position (bookId, chapterIndex, pageIndex, scrollOffset, progress, timestamp).
- ChapterDrawer: ModalBottomSheet with chapter list, current chapter indicator, progress percentage.
- Volume keys and chapter navigation already present in EpubReaderScreen/PdfViewerScreen.
- ':app:assembleOssDebug' passes.
2026-06-24 18:08:00 +03:00
Atte149
1dc3bf2fcb docs(AGENTS): Phase 4 completed 2026-06-24 18:02:15 +03:00
Atte149
cacfe2c7c4 feat(phase4): port RSVP engine, audio player, TTS repo, workers from Book's Story
- RSVP: RsvpEngine, RsvpTokenizer, RsvpToken, ReaderText model (domain models ported).
- Audio: AudioPlaybackService (@AndroidEntryPoint, ExoPlayer, MediaSession), AudioTrack, AudioPlayerProgress, Book stub.
- TTS: RemoteTtsRepository interface + RemoteTtsRepositoryImpl (wraps BookshelfApiRepository).
- Workers: no-op stubs for CacheDownloadWorker, ProgressSyncWorker, TtsDownloadWorker.
- DI: PlaybackModule provides ExoPlayer singleton.
- Strings: added audio_playback_channel resources.
- ':app:assembleOssDebug' passes.
2026-06-24 18:01:57 +03:00
Atte149
f7bffd0b06 docs(AGENTS): Phase 3 completed 2026-06-24 17:07:15 +03:00
Atte149
1c0eca423d feat(ui): Phase 3 Myne-style Material You 3 redesign
- Add Shapes.kt with rounded shape scheme (8-32dp corners) wired into AppTheme.
- BookshelfLibraryScreen: round cards (shapes.large), surfaceContainerLow, CenterAlignedTopAppBar, onSurfaceVariant labels, larger padding.
- ServerSettingsScreen: section cards with round shapes, scrollable, CenterAlignedTopAppBar.
- Dynamic color already enabled via AppTheme (Android 12+ dynamicColorScheme + materialkolor seed).
- ':app:assembleOssDebug' passes.
2026-06-24 17:05:33 +03:00
Atte149
d0ceeef24f docs(AGENTS): Phase 2 completed 2026-06-24 16:42:27 +03:00
Atte149
fef33afe69 feat(backend): Phase 2 complete — bookshelf-api + ABS, remove OPDS/Gutenberg/LocalFolder/CloudSync
Phase 2.4: download/open ebooks from server
- BookshelfViewModel now extends AndroidViewModel, downloads books via BookshelfApiRepository.downloadEbook
- Saves to cache dir, emits Uri via SharedFlow, opens in reader via MainViewModel.onFileSelected
- Download progress indicator in BookshelfLibraryScreen item cards

Phase 2.5: remove legacy backends
- Delete reader/opds/ package (OpdsModels, OpdsParser, OpdsRepository, OpdsViewModel)
- Delete CloudSyncTrace.kt and FolderSyncWorker.kt
- Delete OpdsTab, rememberOpdsCoverImageLoader, OpdsCatalogCard, OpdsNavigationCard, OpdsBookCard, OpdsBookDetailsSheet from LibraryScreen
- Delete FolderSyncScreen, FolderCard, EditFolderFiltersDialog from LibraryScreen
- Remove 'Catalogs' tab from LibraryScreen tabTitles
- Add CloudSyncTraceStub.kt with no-op stubs for logCloudSyncTrace/Error/Annotation variants
- Add FolderSyncWorkerStub.kt as no-op CoroutineWorker with constants
- Fix OpdsStreamDocumentWrapper to use plain OkHttpClient
- ':app:assembleOssDebug' passes (APK ~80 MB).
2026-06-24 16:41:38 +03:00
Atte149
1666ae0fe2 feat(bookshelf): add ServerSettingsScreen for bookshelf-api/ABS config
- Create ServerSettingsViewModel (Hilt) backed by SharedPreferences ServerSettings.
- Create ServerSettingsScreen Compose UI with URL/token fields and Save button.
- Add SERVER_SETTINGS_ROUTE to AppDestinations and wire in AppNavigation.
- Add settings icon in BookshelfLibraryScreen top bar to open settings.
- ':app:assembleOssDebug' passes.
2026-06-24 16:01:03 +03:00
Atte149
d999e9df77 feat(bookshelf): add BookshelfViewModel, LibraryScreen and nav entry
- Create BookshelfViewModel (Hilt) for browsing remote libraries via BookshelfApiRepository.
- Create BookshelfLibraryScreen Compose UI: libraries list → items list.
- Add BOOKSHELF_LIBRARY_ROUTE to AppDestinations and wire composable in AppNavigation using hiltViewModel().
- Add Cloud icon button in LibraryScreen top bar to open server library.
- ':app:assembleOssDebug' passes.
2026-06-24 15:45:57 +03:00
Atte149
a8ad2bf900 feat(backend): add thin BookshelfApiRepository and AudiobookshelfRepository
- BookshelfApiRepository exposes all bookshelf-api endpoints as Result<T>.
- AudiobookshelfRepository exposes direct ABS audio/cover/progress endpoints.
- Wire both repositories into BackendModule for Hilt injection.
- ':app:assembleOssDebug' still passes.
2026-06-24 11:38:44 +03:00
Atte149
907d7c8d24 docs(AGENTS): Phase 2.1 completed, Phase 2.2 pending 2026-06-24 11:15:34 +03:00
Atte149
88113d9de6 feat(backend): Phase 2.1 scaffolding for bookshelf-api + ABS
- Port BookshelfApiService, AudiobookshelfApiService and all models from Book's Story.
- Add Hilt 2.56.1 plugin, @HiltAndroidApp, NetworkModule, BackendModule.
- Add Retrofit/OkHttp/Kotlinx Serialization dependencies.
- Provide minimal SharedPreferences-based ServerSettings for URL/token storage.
- Copy UriUtils (fixUriScheme/normalizeUri) from Book's Story.
- No wiring into UI yet; OPDS/Gutenberg/LocalFolder still present.
- ':app:assembleOssDebug' passes (APK ~80 MB).
2026-06-24 11:15:24 +03:00
Atte149
9e30933077 docs(AGENTS): Phase 1 completed, Phase 2 pending 2026-06-24 10:36:18 +03:00
Atte149
0d9439e8c3 feat(build): KMP → Android-only migration (Phase 1.2)
- Delete desktopApp/ and shared/ modules.
- Merge shared commonMain + androidMain + readerJvmMain sources into app/src/main/kotlin.
- Resolve expect/actual declarations by keeping android implementations.
- Remove project(':shared') dependency and KMP plugins from build files.
- Simplify settings.gradle.kts to single :app module; root project renamed BookReader.
- Remove JVM-only code (javax.imageio/ImageIO in SharedJvmBookLoader).
- Make LocalFolderSync helpers public for cross-package use in Android module.
- Baseline ':app:assembleOssDebug' passes (APK ~80 MB).
2026-06-24 10:36:09 +03:00
Atte149
ab9a2dcfeb docs(AGENTS): Phase 1.1 baseline completed + build env notes 2026-06-24 08:33:27 +03:00
Atte149
83e4b420d4 fix(build): align Kotlin/KSP/Compose and force stdlib resolution
- Use Kotlin 2.2.0, KSP 2.2.0-2.0.2, Compose Multiplatform 1.8.2 to match
  transitive dependencies compiled with Kotlin metadata 2.3.0.
- Add root resolutionStrategy force for kotlin-stdlib 2.1.20/2.1.20 variants
  to keep runtime consistent while compiler reads 2.3.0 metadata.
- Baseline ':app:assembleOssDebug' now passes, APK ~80 MB.
2026-06-24 08:33:12 +03:00
Atte149
73f16be140 fix(build): baseline configuration and proxy setup
- Downgrade AGP 9.0.0 -> 8.9.2, Kotlin 2.2.10 -> 2.1.20, KSP and Compose Multiplatform to compatible versions.
- Switch Gradle wrapper to 8.13 (8.11.1 download unavailable, 9.5.1 incompatible with AGP 8.9).
- Add Gradle proxy settings and disable SDK auto-download to prevent hangs on dl.google.com.
- Remove generated gradle-daemon-jvm.properties causing JetBrains JDK lookup failure.
- Fix JvmVendorSpec.ANY incompatibility on the new toolchain.
- Temporarily disable externalNativeBuild until NDK 27.0.12077973 is installed.
- Baseline ':app:tasks' now passes.
2026-06-24 08:12:32 +03:00
385 changed files with 2964 additions and 53192 deletions

View file

@ -62,15 +62,39 @@
## Build
```bash
# Required env on this server (proxy + Android SDK)
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
export ANDROID_HOME=/home/dueattendant149/android-sdk
export ANDROID_SDK_ROOT=/home/dueattendant149/android-sdk
./gradlew :app:assembleOssDebug
```
**Known build workarounds (Phase 1 baseline):**
- Gradle wrapper pinned to 8.13 (8.11.1 unavailable behind proxy; 9.5.1 incompatible with AGP 8.9).
- `externalNativeBuild` temporarily disabled because NDK 27.0.12077973 is requested but only NDK 28.2.x is installed.
- Network proxy is configured in `gradle.properties` (`systemProp.http[s].proxyHost/Port`).
## Status
- Phase 0: clone + package rename — completed
- Phase 1: KMP → Android-only — pending
- Phase 2: backend swap (bookshelf-api + ABS) — pending
- Phase 3: UI redesign (Myne-style) — pending
- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending
- Phase 5: reader engine integration — pending
- Phase 6: build, test, deploy — pending
- Phase 1: KMP → Android-only — completed
- Phase 2: backend swap (bookshelf-api + ABS) — completed
- Phase 2.1: bookshelf-api + ABS API services/models, Hilt DI, Retrofit/OkHttp
- Phase 2.2: BookshelfViewModel + BookshelfLibraryScreen + nav entry
- Phase 2.3: ServerSettingsScreen (URL/token config)
- Phase 2.4: download/open ebook from server in reader
- Phase 2.5: removed OPDS/Gutenberg/LocalFolder/CloudSync code + stubs
- Phase 3: UI redesign (Myne-style) — completed
- Material You 3 shape scheme (round cards 8-32dp)
- Dynamic color (Android 12+ dynamicColorScheme + materialkolor seed)
- BookshelfLibraryScreen + ServerSettingsScreen redesigned with round cards, CenterAlignedTopAppBar
- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — completed
- RSVP engine (RsvpEngine, RsvpTokenizer, RsvpToken, ReaderText)
- Audio player (AudioPlaybackService, ExoPlayer, MediaSession, PlaybackModule)
- TTS repository (RemoteTtsRepository + impl wrapping BookshelfApiRepository)
- Workers (no-op stubs for CacheDownload, ProgressSync, TtsDownload)
- Phase 5: reader engine integration — completed
- Checkpoint model (serializable reading position)
- ChapterDrawer (ModalBottomSheet chapter list)
- Volume keys + chapter navigation already present
- Phase 6: build, test, deploy — in progress

View file

@ -11,6 +11,7 @@ plugins {
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.ksp)
alias(libs.plugins.hilt)
id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover)
}
@ -46,7 +47,9 @@ fun configuredAppLocaleTags(): Set<String> {
}
kotlin {
jvmToolchain(21)
jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
android {
@ -64,11 +67,13 @@ android {
.map { it.toAndroidResourceConfiguration() }
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
/*
externalNativeBuild {
cmake {
cppFlags += ""
}
}
*/
buildConfigField("boolean", "IS_PRO", "false")
buildConfigField("boolean", "IS_OFFLINE", "false")
}
@ -162,12 +167,14 @@ android {
singleVariant("release") {
}
}
/*
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
*/
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.all {
@ -211,8 +218,6 @@ kover {
//noinspection UseTomlInstead
dependencies {
implementation(project(":shared"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)
@ -224,6 +229,16 @@ dependencies {
implementation(libs.androidx.material3.window.size.class1.android)
implementation(libs.androidx.credentials)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
// Networking
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp.logging)
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(platform(libs.androidx.compose.bom))

View file

@ -52,6 +52,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.LocalContext
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.util.UnstableApi
@ -59,6 +61,7 @@ import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
import org.dueattendant149.bookreader.feedback.FeedbackScreen
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
@ -79,6 +82,8 @@ object AppDestinations {
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
const val BOOKSHELF_LIBRARY_ROUTE = "bookshelf_library"
const val SERVER_SETTINGS_ROUTE = "server_settings"
}
fun shouldInterceptAppNavBack(
@ -427,6 +432,34 @@ fun AppNavigation(
onBackClick = { navController.popBackStackIfReady() }
)
}
composable(route = AppDestinations.BOOKSHELF_LIBRARY_ROUTE) {
val bookshelfViewModel: org.dueattendant149.bookreader.bookshelf.BookshelfViewModel = hiltViewModel()
LaunchedEffect(bookshelfViewModel) {
bookshelfViewModel.downloadedFile.collect { uri ->
Timber.d("Downloaded book $uri, opening in reader")
viewModel.onFileSelected(uri, isFromRecent = false, isExternalIntent = false)
}
}
BookshelfLibraryScreen(
viewModel = bookshelfViewModel,
onItemClick = { item ->
Timber.d("Bookshelf item selected: ${item.id}")
bookshelfViewModel.downloadBook(item)
},
onOpenSettings = {
navController.navigateIfReady(AppDestinations.SERVER_SETTINGS_ROUTE)
}
)
}
composable(route = AppDestinations.SERVER_SETTINGS_ROUTE) {
val settingsViewModel: org.dueattendant149.bookreader.bookshelf.ServerSettingsViewModel = hiltViewModel()
org.dueattendant149.bookreader.bookshelf.ServerSettingsScreen(
viewModel = settingsViewModel,
onBackClick = { navController.popBackStackIfReady() }
)
}
}
AnimatedVisibility(

View file

@ -1,70 +0,0 @@
package org.dueattendant149.bookreader
import android.util.Log
import org.dueattendant149.bookreader.data.BookMetadata
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.effectiveAnnotationModifiedTimestamp
import org.dueattendant149.bookreader.data.effectiveReadingPositionModifiedTimestamp
import timber.log.Timber
internal const val CloudSyncTraceTag = "EpistemeCloudSync"
internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
internal fun logCloudSyncTrace(message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.d(CloudSyncTraceTag, text)
Timber.tag(CloudSyncTraceTag).d(text)
}
internal fun logCloudSyncError(error: Throwable, message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.e(CloudSyncTraceTag, text, error)
Timber.tag(CloudSyncTraceTag).e(error, text)
}
internal fun logCloudAnnotationSyncTrace(message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.d(CloudAnnotationSyncTraceTag, text)
Timber.tag(CloudAnnotationSyncTraceTag).d(text)
}
internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
if (!BuildConfig.DEBUG) return
val text = message()
Log.e(CloudAnnotationSyncTraceTag, text, error)
Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
}
internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
"contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
"bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
"annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
"page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
"progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
"hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
"highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
}
internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
val value = this ?: return "null"
return if (value.length <= maxLength) value else value.take(maxLength) + "..."
}
internal fun String?.cloudSyncAnnotationSummary(): String {
val value = this?.trim() ?: return "null"
return when {
value.isEmpty() -> "blank"
value == "[]" -> "empty"
else -> "present(${value.length})"
}
}

View file

@ -0,0 +1,37 @@
package org.dueattendant149.bookreader
/**
* Stubs for removed CloudSyncTrace.kt. All calls are no-ops.
*/
internal inline fun logCloudSyncTrace(crossinline message: () -> String) {
// no-op: cloud sync removed
}
internal inline fun logCloudSyncError(throwable: Throwable, crossinline message: () -> String) {
// no-op: cloud sync removed
}
internal inline fun logCloudSyncError(crossinline message: () -> String) {
// no-op: cloud sync removed
}
internal inline fun logCloudAnnotationSyncTrace(crossinline message: () -> String) {
// no-op: cloud annotation sync removed
}
internal inline fun logCloudAnnotationSyncError(throwable: Throwable, crossinline message: () -> String) {
// no-op: cloud annotation sync removed
}
internal fun Any?.cloudSyncTraceSummary(label: String = ""): String = ""
internal fun Any?.cloudSyncAnnotationSummary(): String = ""
internal fun Any?.cloudSyncPreview(): String = this?.toString() ?: ""
internal fun String.cloudSyncPreview(): String = this
internal fun String.cloudSyncPreview(maxLen: Int): String = take(maxLen)
internal fun String?.cloudSyncPreviewOrEmpty(): String = this ?: ""

View file

@ -1,773 +0,0 @@
/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
// FolderSyncWorker.kt
package org.dueattendant149.bookreader
import android.content.Context
import timber.log.Timber
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkerParameters
import androidx.work.WorkManager
import org.dueattendant149.bookreader.data.RecentFileItem
import org.dueattendant149.bookreader.data.RecentFilesRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import androidx.core.content.edit
import org.dueattendant149.bookreader.data.LocalSyncUtils
import org.dueattendant149.bookreader.data.FolderBookMetadata
import org.dueattendant149.bookreader.data.toSharedFolderBookMetadata
import org.dueattendant149.bookreader.shared.BookItem as SharedBookItem
import org.dueattendant149.bookreader.shared.EpubAnnotationSerializer
import org.dueattendant149.bookreader.shared.EpubBookmark
import org.dueattendant149.bookreader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import org.dueattendant149.bookreader.shared.LocalFolderSyncEngine
import org.dueattendant149.bookreader.shared.ReaderLocator
import org.dueattendant149.bookreader.shared.SharedFolderScannedFile
import org.dueattendant149.bookreader.shared.SharedReaderScreenState
import org.dueattendant149.bookreader.shared.reader.ReaderBookmark
import java.io.File
import android.provider.DocumentsContract
class FolderSyncWorker(
private val appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext)
companion object {
const val WORK_NAME = "FolderSyncWorker"
const val WORK_NAME_ONETIME = "FolderSyncWorker_OneTime"
const val KEY_METADATA_ONLY = "key_metadata_only"
const val KEY_TARGET_FOLDER_URI = "key_target_folder_uri"
private val syncMutex = Mutex()
}
override suspend fun doWork(): Result {
val workerStart = ReaderPerfLog.nowNanos()
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
val folders = SyncedFolderPrefs.decodeSyncedFolders(
jsonString = jsonString,
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
)
if (folders.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
return Result.success()
}
val enabledFolders = folders.filter { it.localSyncEnabled }
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
enabledFolders
} else {
enabledFolders.filter { it.uriString == targetFolderUri }
}
if (foldersToProcess.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
return Result.success()
}
ReaderPerfLog.d(
"FolderSync worker start folders=${foldersToProcess.size}/${folders.size} " +
"target=${targetFolderUri ?: "ALL"} metadataOnly=$isMetadataOnly"
)
return withContext(Dispatchers.IO) {
syncMutex.withLock {
var allSuccess = true
for (folderConfig in foldersToProcess) {
val success = performSyncForFolder(folderConfig, isMetadataOnly)
if (!success) allSuccess = false
}
if (jsonString != null) {
try {
val array = org.json.JSONArray(jsonString)
val now = System.currentTimeMillis()
val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
if (obj.optString("uri") in processedUris) {
obj.put("lastScanTime", now)
}
}
prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
} catch (_: Exception) {}
}
val elapsed = ReaderPerfLog.elapsedMs(workerStart)
ReaderPerfLog.i(
"FolderSync worker finished status=${if (allSuccess) "success" else "failure"} " +
"folders=${foldersToProcess.size} elapsed=${elapsed}ms"
)
if (allSuccess) Result.success() else Result.failure()
}
}
}
private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
val folderUriString = folderConfig.uriString
val allowedFileTypes = folderConfig.allowedFileTypes
if (folderUriString.isBlank()) return true
val folderUri = folderUriString.toUri()
val folderStart = ReaderPerfLog.nowNanos()
var dirsScanned = 0
var filesSeen = 0
var supportedBooksSeen = 0
var dbFlushes = 0
var sidecarsImported = 0
var stoppedForUnlinkedFolder = false
try {
if (!isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder skipped: no longer linked folder=$folderUriString")
return true
}
try {
appContext.contentResolver.takePersistableUriPermission(
folderUri,
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
)
} catch (_: SecurityException) {
return false
}
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
if (documentTree == null || !documentTree.isDirectory) {
return false
}
ReaderPerfLog.d("FolderSync phase legacy-sidecar-migration mapped-to-shared")
val folderMetadataMap = ReaderPerfLog.measureSuspend(
name = "FolderSync phase metadata-sidecars",
minLogMs = 25L,
details = { "metadataOnly=$metadataOnly" }
) {
LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
}
ReaderPerfLog.d(
"FolderSync metadata-sidecars records=${folderMetadataMap.size} metadataOnly=$metadataOnly folder=$folderUriString"
)
val existingFolderBooks = ReaderPerfLog.measureSuspend(
name = "FolderSync phase load-existing-db",
minLogMs = 25L
) {
recentFilesRepository.getFilesBySourceFolder(folderUriString)
}
val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
val scanResult = if (metadataOnly) {
AndroidFolderScanResult()
} else {
ReaderPerfLog.measureSuspend(
name = "FolderSync phase scan-folder",
minLogMs = 25L
) {
scanFolderFiles(
folderUri = folderUri,
folderUriString = folderUriString,
allowedFileTypes = allowedFileTypes
)
}
}
dirsScanned = scanResult.dirsScanned
filesSeen = scanResult.filesSeen
supportedBooksSeen = scanResult.files.size
stoppedForUnlinkedFolder = scanResult.stoppedForUnlinkedFolder
if (isStopped || stoppedForUnlinkedFolder) {
ReaderPerfLog.w(
"FolderSync folder aborted before shared engine stopped=$isStopped " +
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
)
return true
}
val nowMillis = System.currentTimeMillis()
val folder = SyncedFolder(
uriString = folderUriString,
name = documentTree.name ?: folderConfig.name,
lastScanTime = nowMillis,
allowedFileTypes = allowedFileTypes,
localSyncEnabled = true
)
val sharedState = SharedReaderScreenState(
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
syncedFolders = listOf(folder)
)
val syncResult = LocalFolderSyncEngine.syncFolder(
state = sharedState,
folder = folder,
files = scanResult.files,
remoteMetadata = folderMetadataMap.mapValues { it.value.toSharedFolderBookMetadata() },
nowMillis = nowMillis,
metadataOnly = metadataOnly
)
if (syncResult.idMigrations.isNotEmpty()) {
val preloadedSidecars = ReaderPerfLog.measureSuspend(
name = "FolderSync phase migration-sidecars",
minLogMs = 25L
) {
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri).toMutableMap()
}
syncResult.idMigrations.forEach { (oldId, newId) ->
Timber.tag("FolderSync").i("Migrating folder book ID via shared engine $oldId -> $newId")
migrateFolderBookId(
folderUriString = folderUriString,
oldId = oldId,
newId = newId,
folderMetadataMap = folderMetadataMap,
preloadedSidecars = preloadedSidecars,
existingItemsMap = existingItemsMap
)
}
}
if (!isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder abort: folder unlinked before DB write folder=$folderUriString")
stoppedForUnlinkedFolder = true
return true
}
val scannedFilesById = scanResult.files.associateBy { it.stableBookId }
val syncedItems = syncResult.state.rawLibraryBooks.map { book ->
val existing = existingItemsMap[book.id]
val metadata = appliedMetadataFor(
book = book,
existing = existing,
metadata = folderMetadataMap[book.id]
)
book.toFolderSyncRecentFileItem(
existing = existing,
appliedMetadata = metadata,
scannedFile = scannedFilesById[book.id],
nowMillis = nowMillis
)
}
val changedItems = syncedItems.filter { item -> existingItemsMap[item.bookId] != item }
changedItems
.filter { item ->
val previous = existingItemsMap[item.bookId]
previous != null && folderFileContentChanged(previous, item)
}
.forEach { item ->
Timber.tag("FolderSync").i("File content changed for ${item.displayName}; refreshing extracted metadata.")
recentFilesRepository.clearLocalCachesForBook(item.bookId)
}
if (changedItems.isNotEmpty()) {
recentFilesRepository.addRecentFiles(changedItems)
dbFlushes++
}
if (!metadataOnly && syncResult.removedBookIds.isNotEmpty()) {
Timber.tag("FolderSync").i("Cleaning up ${syncResult.removedBookIds.size} missing folder books.")
recentFilesRepository.deleteFilePermanently(syncResult.removedBookIds.toList())
}
val booksForAnnotationSync = if (metadataOnly) {
syncedItems
} else {
ReaderPerfLog.measureSuspend(
name = "FolderSync phase load-post-scan-db",
minLogMs = 25L
) {
recentFilesRepository.getFilesBySourceFolder(folderUriString)
}
}
sidecarsImported += importAnnotationSidecarsForBooks(
folderUri = folderUri,
folderUriString = folderUriString,
books = booksForAnnotationSync,
phase = if (metadataOnly) "metadata-only" else "post-scan"
)
val elapsed = ReaderPerfLog.elapsedMs(folderStart)
ReaderPerfLog.i(
"FolderSync folder finished metadataOnly=$metadataOnly elapsed=${elapsed}ms " +
"dirs=$dirsScanned entries=$filesSeen supported=$supportedBooksSeen " +
"new=${syncResult.stats.newBooks} updated=${syncResult.stats.updatedBooks} " +
"remoteUpdates=${syncResult.stats.remoteMetadataUpdates} unchanged=${syncResult.stats.unchangedBooks} " +
"removed=${syncResult.stats.removedBooks} migrated=${syncResult.stats.migratedBooks} " +
"dbFlushes=$dbFlushes sidecarsImported=$sidecarsImported " +
"unlinkedAbort=$stoppedForUnlinkedFolder folder=$folderUriString"
)
if (!isStopped && !stoppedForUnlinkedFolder && !metadataOnly) {
if (recentFilesRepository.hasFolderBooksNeedingTextMetadata(folderUriString)) {
ReaderPerfLog.i("FolderSync enqueue metadata extraction folder=$folderUriString")
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>()
.setInputData(
androidx.work.Data.Builder()
.putString(MetadataExtractionWorker.KEY_SOURCE_FOLDER_URI, folderUriString)
.build()
)
.build()
WorkManager.getInstance(appContext).enqueueUniqueWork(
MetadataExtractionWorker.WORK_NAME,
ExistingWorkPolicy.REPLACE,
metaRequest
)
} else {
ReaderPerfLog.d("FolderSync metadata extraction skipped: no pending books folder=$folderUriString")
}
}
return true
} catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
return false
}
}
private suspend fun importAnnotationSidecarsForBooks(
folderUri: android.net.Uri,
folderUriString: String,
books: List<RecentFileItem>,
phase: String
): Int {
if (books.isEmpty()) {
ReaderPerfLog.d("FolderSync phase annotation-sidecars skipped phase=$phase reason=no-books folder=$folderUriString")
return 0
}
val preloadedSidecars = ReaderPerfLog.measureSuspend(
name = "FolderSync phase annotation-sidecars",
minLogMs = 25L,
details = { "phase=$phase" }
) {
LocalSyncUtils.preloadAnnotationSidecars(appContext, folderUri)
}
ReaderPerfLog.d(
"FolderSync annotation-sidecars records=${preloadedSidecars.size} books=${books.size} phase=$phase folder=$folderUriString"
)
if (preloadedSidecars.isEmpty()) return 0
var imported = 0
Timber.tag("FolderAnnotationSync").d("Checking annotation sidecars phase=$phase for ${books.size} books...")
for (book in books) {
if (isStopped || !isFolderStillLinked(folderUriString)) break
val sidecarData = preloadedSidecars[book.bookId] ?: continue
val (remoteTs, jsonPayload) = sidecarData
val safeSlashBookId = book.bookId.replace("/", "_")
val safeRichTextBookId = book.bookId.replace("[^a-zA-Z0-9._-]".toRegex(), "_")
val localFiles = listOf(
File(appContext.filesDir, "annotations/annotation_$safeSlashBookId.json"),
File(appContext.filesDir, "rich_doc_${safeRichTextBookId}.json"),
File(appContext.filesDir, "page_layouts/layout_$safeSlashBookId.json"),
File(appContext.filesDir, "textboxes/textboxes_$safeSlashBookId.json"),
File(appContext.filesDir, "pdf_highlights/highlights_$safeSlashBookId.json")
)
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
if (remoteTs > (localTs + 1000)) {
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
imported++
} else {
Timber.tag("FolderAnnotationSync").v("Sidecar for ${book.displayName} is not newer. Skipping.")
}
}
ReaderPerfLog.i(
"FolderSync annotation-sidecars imported=$imported records=${preloadedSidecars.size} phase=$phase folder=$folderUriString"
)
return imported
}
private data class AndroidFolderScanResult(
val files: List<SharedFolderScannedFile> = emptyList(),
val dirsScanned: Int = 0,
val filesSeen: Int = 0,
val stoppedForUnlinkedFolder: Boolean = false
)
private fun scanFolderFiles(
folderUri: android.net.Uri,
folderUriString: String,
allowedFileTypes: Set<FileType>
): AndroidFolderScanResult {
Timber.tag("FolderSync").d("Phase 2: Scanning physical files using raw ContentResolver...")
val contentResolver = appContext.contentResolver
val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
val dirQueue = ArrayDeque<String>()
val scannedFiles = mutableListOf<SharedFolderScannedFile>()
var dirsScanned = 0
var filesSeen = 0
var stoppedForUnlinkedFolder = false
dirQueue.add(rootDocId)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED
)
while (dirQueue.isNotEmpty()) {
if (isStopped) break
if (!isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder abort: folder unlinked during scan folder=$folderUriString")
stoppedForUnlinkedFolder = true
break
}
val currentDocId = dirQueue.removeFirst()
dirsScanned++
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
try {
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
while (cursor.moveToNext() && !isStopped && !stoppedForUnlinkedFolder) {
val docId = cursor.getString(idCol)
val name = cursor.getString(nameCol) ?: ""
val mimeType = cursor.getString(mimeCol)
filesSeen++
if (filesSeen % 100 == 0 && !isFolderStillLinked(folderUriString)) {
ReaderPerfLog.w("FolderSync folder abort: folder unlinked after entries=$filesSeen folder=$folderUriString")
stoppedForUnlinkedFolder = true
break
}
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
if (!name.startsWith(".") && name != LOCAL_FOLDER_SYNC_DATA_DIR) {
dirQueue.add(docId)
}
continue
}
val type = getFileType(name, mimeType)
if (
type == null ||
type !in allowedFileTypes ||
!isLocalFolderSyncEligibleFile(name, mimeType) ||
name.endsWith(".json") ||
name.startsWith(".")
) {
continue
}
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
val relativePath = buildRelativePath(rootDocId, docId, name)
scannedFiles += SharedFolderScannedFile(
name = name,
path = docUri.toString(),
sourceFolder = folderUriString,
relativePath = relativePath,
type = type,
size = if (!cursor.isNull(sizeCol)) cursor.getLong(sizeCol) else 0L,
lastModified = if (!cursor.isNull(modCol)) cursor.getLong(modCol) else 0L
)
}
}
} catch (e: Exception) {
Timber.tag("FolderSync").e(e, "Failed to query children for docId: $currentDocId")
}
if (stoppedForUnlinkedFolder) break
}
return AndroidFolderScanResult(
files = scannedFiles,
dirsScanned = dirsScanned,
filesSeen = filesSeen,
stoppedForUnlinkedFolder = stoppedForUnlinkedFolder
)
}
private fun RecentFileItem.toFolderSyncSharedBookItem(): SharedBookItem {
return SharedBookItem(
id = bookId,
path = uriString,
type = type,
displayName = displayName,
timestamp = lastModifiedTimestamp,
coverImagePath = coverImagePath,
title = title,
author = author,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
progressPercentage = progressPercentage,
isRecent = isRecent,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
sourceFolder = sourceFolderUri,
folderTextMetadataParsed = folderTextMetadataParsed,
seriesName = seriesName,
seriesIndex = seriesIndex,
lastPageIndex = lastPage,
readerPosition = readerPositionOrNull(),
readerBookmarks = parseReaderBookmarks(),
readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
)
}
private fun SharedBookItem.toFolderSyncRecentFileItem(
existing: RecentFileItem?,
appliedMetadata: FolderBookMetadata?,
scannedFile: SharedFolderScannedFile?,
nowMillis: Long
): RecentFileItem {
val contentChanged = existing != null && folderFileContentChanged(existing, this)
val localModifiedTimestamp = when {
appliedMetadata != null -> appliedMetadata.lastModifiedTimestamp
contentChanged && fileContentModifiedTimestamp > 0L -> fileContentModifiedTimestamp
timestamp > 0L -> timestamp
else -> nowMillis
}
val legacyPosition = readerPosition
val mappedBookmarksJson = readerBookmarks.toAndroidBookmarksJson(id)
val mappedHighlightsJson = readerHighlights
.takeIf { it.isNotEmpty() }
?.let(EpubAnnotationSerializer::highlightsToJson)
val bookmarksJson = if (appliedMetadata != null || existing == null) {
mappedBookmarksJson ?: appliedMetadata?.bookmarksJson ?: existing?.bookmarksJson
} else {
existing.bookmarksJson
}
val highlightsJson = if (appliedMetadata != null || existing == null) {
mappedHighlightsJson ?: appliedMetadata?.highlightsJson ?: existing?.highlightsJson
} else {
existing.highlightsJson
}
return RecentFileItem(
bookId = id,
uriString = path,
type = type,
displayName = scannedFile?.name ?: existing?.displayName ?: displayName,
timestamp = when {
existing == null -> timestamp.takeIf { it > 0L } ?: localModifiedTimestamp
appliedMetadata?.isRecent == true -> appliedMetadata.lastModifiedTimestamp
else -> existing.timestamp
},
coverImagePath = coverImagePath,
title = title,
author = author,
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
progressPercentage = progressPercentage,
isRecent = isRecent,
isAvailable = true,
lastModifiedTimestamp = localModifiedTimestamp,
isDeleted = false,
bookmarksJson = bookmarksJson,
sourceFolderUri = sourceFolder,
isReflowPreferred = existing?.isReflowPreferred ?: false,
customName = appliedMetadata?.customName ?: existing?.customName,
highlightsJson = highlightsJson,
fileSize = fileSize,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
seriesName = seriesName,
seriesIndex = seriesIndex,
description = description,
originalTitle = originalTitle,
originalAuthor = originalAuthor,
originalSeriesName = originalSeriesName,
originalSeriesIndex = originalSeriesIndex,
originalDescription = originalDescription,
folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = existing?.tags.orEmpty()
)
}
private fun appliedMetadataFor(
book: SharedBookItem,
existing: RecentFileItem?,
metadata: FolderBookMetadata?
): FolderBookMetadata? {
if (metadata == null) return null
val existingModified = existing?.lastModifiedTimestamp ?: Long.MIN_VALUE
return metadata.takeIf { existing == null || it.lastModifiedTimestamp > existingModified }
}
private fun RecentFileItem.readerPositionOrNull(): ReaderLocator? {
if (lastChapterIndex == null && lastPage == null && lastPositionCfi.isNullOrBlank()) return null
return ReaderLocator.fromLegacy(
chapterIndex = lastChapterIndex,
cfi = lastPositionCfi,
pageIndex = lastPage
)
}
private fun RecentFileItem.parseReaderBookmarks(): List<ReaderBookmark> {
return EpubAnnotationSerializer.parseBookmarksJson(bookmarksJson)
.mapIndexed { index, bookmark ->
val locator = bookmark.locator.withFallbacks(
chapterIndex = bookmark.chapterIndex,
cfi = bookmark.cfi,
pageIndex = bookmark.pageInChapter?.minus(1),
textQuote = bookmark.snippet
)
val pageIndex = locator.pageIndex ?: bookmark.pageInChapter?.minus(1) ?: 0
ReaderBookmark(
id = "bookmark_${bookId}_$index",
pageIndex = pageIndex.coerceAtLeast(0),
chapterTitle = bookmark.chapterTitle,
preview = bookmark.snippet,
locator = locator
)
}
}
private fun List<ReaderBookmark>.toAndroidBookmarksJson(bookId: String): String? {
val bookmarks = mapIndexed { index, bookmark ->
val locator = bookmark.locator
val chapterIndex = locator.chapterIndex ?: 0
val cfi = locator.cfi ?: "android:$bookId:$index:${bookmark.pageIndex}"
EpubBookmark(
cfi = cfi,
chapterTitle = bookmark.chapterTitle,
label = null,
snippet = bookmark.preview,
pageInChapter = bookmark.pageIndex + 1,
totalPagesInChapter = null,
chapterIndex = chapterIndex,
locator = locator.withFallbacks(
chapterIndex = chapterIndex,
cfi = cfi,
pageIndex = bookmark.pageIndex,
textQuote = bookmark.preview
)
)
}
return bookmarks.takeIf { it.isNotEmpty() }?.let(EpubAnnotationSerializer::bookmarksToJson)
}
private fun folderFileContentChanged(previous: RecentFileItem, next: RecentFileItem): Boolean {
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
return sizeChanged || modifiedChanged
}
private fun folderFileContentChanged(previous: RecentFileItem, next: SharedBookItem): Boolean {
val sizeChanged = previous.fileSize > 0L && next.fileSize > 0L && previous.fileSize != next.fileSize
val modifiedChanged = next.fileContentModifiedTimestamp > 0L &&
previous.fileContentModifiedTimestamp != next.fileContentModifiedTimestamp
return sizeChanged || modifiedChanged
}
private fun isFolderStillLinked(folderUriString: String): Boolean {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
return SyncedFolderPrefs.isLocalSyncEnabled(
jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
folderUriString = folderUriString,
syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
)
}
private fun getFileType(name: String, mimeType: String?): FileType? {
return resolveFileTypeFromMetadata(name, mimeType)
}
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
val rootPath = rootDocId.substringAfter(':', "")
val docPath = docId.substringAfter(':', "")
if (docPath.isBlank()) return fallbackName
val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) {
docPath.removePrefix(rootPath).trimStart('/')
} else {
docPath.substringAfterLast('/', fallbackName)
}
return relative.ifBlank { fallbackName }
}
private suspend fun migrateFolderBookId(
folderUriString: String,
oldId: String,
newId: String,
folderMetadataMap: MutableMap<String, FolderBookMetadata>,
preloadedSidecars: MutableMap<String, Pair<Long, String>>,
existingItemsMap: MutableMap<String, RecentFileItem>
) {
if (oldId == newId) return
recentFilesRepository.migrateBookIdLocally(oldId, newId)
val oldMetadata = folderMetadataMap.remove(oldId)
if (oldMetadata != null && newId !in folderMetadataMap) {
val migratedMetadata = oldMetadata.copy(bookId = newId)
LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata)
folderMetadataMap[newId] = migratedMetadata
}
val oldSidecar = preloadedSidecars.remove(oldId)
if (oldSidecar != null && newId !in preloadedSidecars) {
LocalSyncUtils.saveAnnotationSidecar(
context = appContext,
sourceFolderUri = folderUriString.toUri(),
bookId = newId,
jsonPayload = oldSidecar.second,
timestamp = oldSidecar.first
)
preloadedSidecars[newId] = oldSidecar
}
LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId)
existingItemsMap.remove(oldId)
recentFilesRepository.getFileByBookId(newId)?.let {
existingItemsMap[newId] = it
}
}
}

View file

@ -0,0 +1,28 @@
package org.dueattendant149.bookreader
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Stub for removed FolderSyncWorker. Keeps constants and a no-op CoroutineWorker
* so existing WorkManager enqueue calls compile.
*/
const val KEY_METADATA_ONLY = "metadata_only"
const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
const val KEY_TRIGGER_REASON = "trigger_reason"
class FolderSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = Result.success()
companion object {
const val WORK_NAME = "folder_sync_work"
const val WORK_NAME_ONETIME = "folder_sync_work_onetime"
const val KEY_METADATA_ONLY = "metadata_only"
const val KEY_TARGET_FOLDER_URI = "target_folder_uri"
const val KEY_TRIGGER_REASON = "trigger_reason"
}
}

View file

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

View file

@ -1,10 +0,0 @@
package org.dueattendant149.bookreader.opds
typealias OpdsCatalog = org.dueattendant149.bookreader.shared.opds.OpdsCatalog
typealias OpdsFacet = org.dueattendant149.bookreader.shared.opds.OpdsFacet
typealias OpdsFeed = org.dueattendant149.bookreader.shared.opds.OpdsFeed
typealias OpdsAuthor = org.dueattendant149.bookreader.shared.opds.OpdsAuthor
typealias OpdsAcquisition = org.dueattendant149.bookreader.shared.opds.OpdsAcquisition
typealias OpdsEntry = org.dueattendant149.bookreader.shared.opds.OpdsEntry
typealias OpdsDownloadState = org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadState
typealias OpdsScreenState = org.dueattendant149.bookreader.shared.opds.SharedOpdsScreenState

View file

@ -1,3 +0,0 @@
package org.dueattendant149.bookreader.opds
typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser

View file

@ -1,214 +0,0 @@
package org.dueattendant149.bookreader.opds
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import org.dueattendant149.bookreader.shared.opds.SharedOpdsCatalogs
import org.dueattendant149.bookreader.shared.opds.SharedOpdsRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import timber.log.Timber
import java.security.MessageDigest
import java.util.UUID
class OpdsRepository(context: Context) : SharedOpdsRepository {
private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE)
private val parser = OpdsParser()
companion object {
private const val KEY_CATALOGS_JSON = "opds_catalogs_json"
val sharedHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.addInterceptor { chain ->
val originalRequest = chain.request()
val requestWithUserAgent = originalRequest.newBuilder()
.header("User-Agent", "EpistemeReader/1.0 (Android)")
.build()
chain.proceed(requestWithUserAgent)
}
.build()
}
}
private val httpClient = sharedHttpClient
override fun loadCatalogs(): List<OpdsCatalog> {
val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
val decodedCatalogs = SharedOpdsCatalogs.decode(jsonString)
val catalogs = decodedCatalogs.ifEmpty {
SharedOpdsCatalogs.defaultCatalogs { UUID.randomUUID().toString() }
}
if (decodedCatalogs.isEmpty()) {
saveCatalogs(catalogs)
}
return catalogs
}
fun getCatalogs(): List<OpdsCatalog> = loadCatalogs()
override suspend fun getSearchTemplate(
openSearchUrl: String,
username: String?,
password: String?
): String? = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(openSearchUrl).build()
val response = getAuthenticatedClient(username, password).newCall(request).execute()
val body = response.body?.string() ?: return@withContext null
parser.extractOpenSearchTemplate(body, openSearchUrl)
} catch (e: Exception) {
Timber.e(e, "Failed to fetch OpenSearch template")
null
}
}
fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
saveCatalogs(
SharedOpdsCatalogs.addCatalog(
catalogs = loadCatalogs(),
title = title,
url = url,
username = username,
password = password,
idFactory = { UUID.randomUUID().toString() }
)
)
}
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
saveCatalogs(SharedOpdsCatalogs.updateCatalog(loadCatalogs(), id, title, url, username, password))
}
fun removeCatalog(id: String) {
saveCatalogs(SharedOpdsCatalogs.removeCatalog(loadCatalogs(), id))
}
override fun saveCatalogs(catalogs: List<OpdsCatalog>) {
prefs.edit { putString(KEY_CATALOGS_JSON, SharedOpdsCatalogs.encode(catalogs)) }
}
fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {
return httpClient.newBuilder()
.authenticator(OpdsAuthenticator(username, password))
.build()
}
class OpdsAuthenticator(private val user: String?, private val pass: String?) : okhttp3.Authenticator {
private var cnonceCount = 0
override fun authenticate(route: okhttp3.Route?, response: okhttp3.Response): Request? {
if (user.isNullOrBlank() || pass.isNullOrBlank()) return null
if (response.request.header("Authorization") != null) {
return null
}
val wwwAuth = response.header("WWW-Authenticate") ?: return null
if (wwwAuth.startsWith("Basic", ignoreCase = true)) {
val credential = okhttp3.Credentials.basic(user, pass)
return response.request.newBuilder().header("Authorization", credential).build()
}
if (wwwAuth.startsWith("Digest", ignoreCase = true)) {
val realm = extractParam(wwwAuth, "realm") ?: ""
val nonce = extractParam(wwwAuth, "nonce") ?: ""
val qop = selectAuthQop(extractParam(wwwAuth, "qop"))
val opaque = extractParam(wwwAuth, "opaque")
cnonceCount++
val nc = String.format("%08x", cnonceCount)
val cnonce = UUID.randomUUID().toString().replace("-", "")
val url = response.request.url
val uri = url.encodedPath + (if (url.encodedQuery != null) "?${url.encodedQuery}" else "")
val ha1 = md5("$user:$realm:$pass")
val ha2 = md5("${response.request.method}:$uri")
val responseHash = if (qop != null) {
md5("$ha1:$nonce:$nc:$cnonce:$qop:$ha2")
} else {
md5("$ha1:$nonce:$ha2")
}
val digestHeader = buildString {
append("Digest username=\"$user\", ")
append("realm=\"$realm\", ")
append("nonce=\"$nonce\", ")
append("uri=\"$uri\", ")
append("response=\"$responseHash\"")
if (qop != null) {
append(", qop=$qop, nc=$nc, cnonce=\"$cnonce\"")
}
if (opaque != null) {
append(", opaque=\"$opaque\"")
}
}
return response.request.newBuilder()
.header("Authorization", digestHeader)
.build()
}
return null
}
private fun extractParam(header: String, param: String): String? {
val match = Regex("$param=\"([^\"]+)\"").find(header) ?: Regex("$param=([^,\\s]+)").find(header)
return match?.groupValues?.get(1)
}
private fun selectAuthQop(value: String?): String? {
return value
?.split(',')
?.map { it.trim().trim('"') }
?.firstOrNull { it.equals("auth", ignoreCase = true) }
}
private fun md5(input: String): String {
val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
}
override suspend fun fetchFeed(url: String, username: String?, password: String?): Result<OpdsFeed> = withContext(Dispatchers.IO) {
Timber.tag("OpdsDebug").d("Starting fetch for URL: $url")
try {
val client = getAuthenticatedClient(username, password)
val request = Request.Builder()
.url(url.trim())
.header("User-Agent", "EpistemeReader/1.0 (Android)")
.build()
Timber.tag("OpdsDebug").d("Executing network call...")
val response = client.newCall(request).execute()
Timber.tag("OpdsDebug").d("Response Code: ${response.code}")
if (!response.isSuccessful) {
val errorMsg = "HTTP ${response.code}: ${response.message}"
Timber.tag("OpdsDebug").e("Fetch failed: $errorMsg")
return@withContext Result.failure(Exception(errorMsg))
}
val bodyString = response.body?.string()
if (bodyString.isNullOrBlank()) {
return@withContext Result.failure(Exception("Empty response body"))
}
val feed = parser.parse(bodyString, url)
Timber.tag("OpdsDebug").d("Parsing complete. Found ${feed.entries.size} entries.")
Result.success(feed)
} catch (e: Exception) {
Timber.tag("OpdsDebug").e(e, "Exception during fetch/parse at URL: $url")
Result.failure(e)
}
}
}

View file

@ -1,172 +0,0 @@
package org.dueattendant149.bookreader.opds
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import org.dueattendant149.bookreader.R
import org.dueattendant149.bookreader.shared.opds.SharedOpdsController
import org.dueattendant149.bookreader.shared.opds.SharedOpdsDownloadNamer
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.Request
import okhttp3.Response
import timber.log.Timber
import java.io.File
import java.util.UUID
class OpdsViewModel(application: Application) : AndroidViewModel(application) {
private val repository = OpdsRepository(application)
private val controller = SharedOpdsController(
repository = repository,
feedLoadErrorMessage = { error ->
application.getString(R.string.opds_error_load_feed, error.message.orEmpty())
},
idFactory = { UUID.randomUUID().toString() }
)
private val _uiState = MutableStateFlow(controller.state)
val uiState: StateFlow<OpdsScreenState> = _uiState.asStateFlow()
fun loadNextPage() {
viewModelScope.launch {
controller.loadNextPage(::emitState)
}
}
fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) {
val downloadUrl = acquisition.url
val catalog = _uiState.value.currentCatalog
viewModelScope.launch {
updateDownloadState(entry.id, OpdsDownloadState(isDownloading = true, progress = 0f))
try {
val tempFile = withContext(Dispatchers.IO) {
val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password)
val request = Request.Builder().url(downloadUrl).build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw OpdsDownloadFailedException(
context.getString(R.string.opds_error_download_failed, response.message)
)
}
val body = response.body
?: throw IllegalStateException(context.getString(R.string.opds_error_empty_response))
val contentLength = body.contentLength()
val ext = resolveOpdsDownloadExtension(acquisition, response)
val safeTitle = SharedOpdsDownloadNamer.safeFileStem(entry.title).take(50)
val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext")
body.byteStream().use { input ->
tempFile.outputStream().use { output ->
val buffer = ByteArray(8 * 1024)
var totalRead = 0L
var lastProgressUpdate = System.currentTimeMillis()
while (true) {
val bytesRead = input.read(buffer)
if (bytesRead == -1) break
output.write(buffer, 0, bytesRead)
totalRead += bytesRead
if (contentLength > 0) {
val now = System.currentTimeMillis()
if (now - lastProgressUpdate > 200) {
val progress = (totalRead.toFloat() / contentLength.toFloat()).coerceIn(0f, 1f)
withContext(Dispatchers.Main) {
updateDownloadState(
entry.id,
OpdsDownloadState(isDownloading = true, progress = progress)
)
}
lastProgressUpdate = now
}
}
}
}
}
tempFile
}
}
onDownloaded(Uri.fromFile(tempFile))
} catch (e: Exception) {
Timber.e(e, "Download error")
val message = if (e is OpdsDownloadFailedException) {
e.message.orEmpty()
} else {
context.getString(R.string.opds_error_download_error, e.message.orEmpty())
}
emitState(controller.setErrorMessage(message))
} finally {
updateDownloadState(entry.id, null)
}
}
}
private fun resolveOpdsDownloadExtension(acquisition: OpdsAcquisition, response: Response): String {
return SharedOpdsDownloadNamer.resolveExtension(
acquisition = acquisition,
contentDisposition = response.header("Content-Disposition"),
urlPathSegment = Uri.parse(acquisition.url).lastPathSegment
)
}
fun addCatalog(title: String, url: String, username: String?, password: String?) {
emitState(controller.addCatalog(title, url, username, password))
}
fun removeCatalog(id: String) {
emitState(controller.removeCatalog(id))
}
fun openCatalog(catalog: OpdsCatalog) {
viewModelScope.launch {
controller.openCatalog(catalog, ::emitState)
}
}
fun openFeedUrl(url: String) {
viewModelScope.launch {
controller.openFeedUrl(url, ::emitState)
}
}
fun navigateBack(): Boolean {
val returnsToPreviousFeed = controller.hasFeedHistory()
viewModelScope.launch {
controller.navigateBack(::emitState)
}
return returnsToPreviousFeed
}
fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
emitState(controller.updateCatalog(id, title, url, username, password))
}
fun search(query: String) {
viewModelScope.launch {
controller.search(query, ::emitState)
}
}
fun clearError() {
emitState(controller.clearError())
}
private fun updateDownloadState(entryId: String, downloadState: OpdsDownloadState?) {
emitState(controller.updateDownloadState(entryId, downloadState))
}
private fun emitState(state: OpdsScreenState) {
_uiState.value = state
}
private class OpdsDownloadFailedException(message: String) : Exception(message)
}

View file

@ -783,20 +783,7 @@ class OpdsStreamDocumentWrapper(
) : ReaderDocument {
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
private val catalog = catalogId?.let {
org.dueattendant149.bookreader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it }
}
private val client = org.dueattendant149.bookreader.opds.OpdsRepository.sharedHttpClient.newBuilder()
.apply {
val streamCatalog = catalog
val username = streamCatalog?.username
val password = streamCatalog?.password
if (!username.isNullOrBlank() && !password.isNullOrBlank()) {
authenticator(org.dueattendant149.bookreader.opds.OpdsRepository.OpdsAuthenticator(username, password))
}
}
.build()
private val client = okhttp3.OkHttpClient.Builder().build()
private fun createErrorPageBytes(): ByteArray {
val bitmap = createBitmap(800, 1200)
@ -827,18 +814,7 @@ class OpdsStreamDocumentWrapper(
}
}
val streamCatalog = catalog
val finalUrlTemplate = if (streamCatalog != null && urlTemplate.startsWith("http")) {
try {
val oldUrl = java.net.URL(urlTemplate)
val newUrl = java.net.URL(streamCatalog.url)
val oldBase = "${oldUrl.protocol}://${oldUrl.authority}"
val newBase = "${newUrl.protocol}://${newUrl.authority}"
urlTemplate.replace(oldBase, newBase)
} catch (_: Exception) {
urlTemplate
}
} else urlTemplate
val finalUrlTemplate = urlTemplate
val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
.replace("{maxWidth}", "1600")

View file

@ -0,0 +1,16 @@
package org.dueattendant149.bookreader.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.ui.unit.dp
/**
* Myne-style Material You 3 shape scheme: generous rounded corners.
*/
val AppShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(12.dp),
medium = RoundedCornerShape(16.dp),
large = RoundedCornerShape(24.dp),
extraLarge = RoundedCornerShape(32.dp),
)

View file

@ -178,6 +178,7 @@ fun AppTheme(
MaterialTheme(
colorScheme = finalColorScheme,
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
shapes = AppShapes,
content = content
)
}

View file

@ -0,0 +1,144 @@
package org.dueattendant149.bookreader.bookshelf
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ServerSettingsScreen(
viewModel: ServerSettingsViewModel,
onBackClick: () -> Unit,
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var bookshelfUrl by remember(uiState.bookshelfUrl) { mutableStateOf(uiState.bookshelfUrl) }
var absUrl by remember(uiState.absUrl) { mutableStateOf(uiState.absUrl) }
var absToken by remember(uiState.absToken) { mutableStateOf(uiState.absToken) }
Scaffold(
topBar = {
androidx.compose.material3.CenterAlignedTopAppBar(
title = { Text("Server settings") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(horizontal = 16.dp, vertical = 8.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
SettingsSectionCard(title = "Bookshelf API") {
OutlinedTextField(
value = bookshelfUrl,
onValueChange = {
bookshelfUrl = it
viewModel.updateBookshelfUrl(it)
},
label = { Text("https://books.dueattendant149.org/") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
SettingsSectionCard(title = "Audiobookshelf") {
OutlinedTextField(
value = absUrl,
onValueChange = {
absUrl = it
viewModel.updateAbsUrl(it)
},
label = { Text("https://abs.dueattendant149.org/") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = absToken,
onValueChange = {
absToken = it
viewModel.updateAbsToken(it)
},
label = { Text("Bearer token") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
}
Button(
onClick = viewModel::save,
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large
) {
Text("Save")
}
if (uiState.saved) {
Text(
"Saved",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}
@Composable
private fun SettingsSectionCard(
title: String,
content: @Composable () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 12.dp)
)
content()
}
}
}

View file

@ -0,0 +1,58 @@
package org.dueattendant149.bookreader.bookshelf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.dueattendant149.bookreader.data.settings.ServerSettings
import javax.inject.Inject
data class ServerSettingsUiState(
val bookshelfUrl: String = "",
val absUrl: String = "",
val absToken: String = "",
val saved: Boolean = false,
)
@HiltViewModel
class ServerSettingsViewModel
@Inject
constructor(
private val serverSettings: ServerSettings,
) : ViewModel() {
private val _uiState = MutableStateFlow(ServerSettingsUiState())
val uiState: StateFlow<ServerSettingsUiState> = _uiState.asStateFlow()
init {
_uiState.value = ServerSettingsUiState(
bookshelfUrl = serverSettings.getBookshelfUrl() ?: "",
absUrl = serverSettings.getAbsUrl() ?: "",
absToken = serverSettings.getAbsToken() ?: "",
)
}
fun updateBookshelfUrl(url: String) {
_uiState.value = _uiState.value.copy(bookshelfUrl = url, saved = false)
}
fun updateAbsUrl(url: String) {
_uiState.value = _uiState.value.copy(absUrl = url, saved = false)
}
fun updateAbsToken(token: String) {
_uiState.value = _uiState.value.copy(absToken = token, saved = false)
}
fun save() {
viewModelScope.launch {
serverSettings.setBookshelfUrl(_uiState.value.bookshelfUrl.trim())
serverSettings.setAbsUrl(_uiState.value.absUrl.trim())
serverSettings.setAbsToken(_uiState.value.absToken.trim())
_uiState.value = _uiState.value.copy(saved = true)
}
}
}

View file

@ -0,0 +1,118 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.audio
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.Bundle
import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import dagger.hilt.android.AndroidEntryPoint
import org.dueattendant149.bookreader.R
import org.dueattendant149.bookreader.audio.Book
import org.dueattendant149.bookreader.MainActivity
import javax.inject.Inject
@AndroidEntryPoint
@OptIn(UnstableApi::class)
class AudioPlaybackService : MediaSessionService() {
@Inject
lateinit var exoPlayer: ExoPlayer
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
val intent = Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val sessionActivity = PendingIntent.getActivity(this, 0, intent, pendingIntentFlags)
mediaSession = MediaSession.Builder(this, exoPlayer)
.setSessionActivity(sessionActivity)
.build()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val book = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent?.getParcelableExtra(EXTRA_BOOK, Book::class.java)
} else {
@Suppress("DEPRECATION")
intent?.getParcelableExtra(EXTRA_BOOK)
}
val notification = buildNotification(book)
startForeground(NOTIFICATION_ID, notification)
return super.onStartCommand(intent, flags, startId)
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession
}
override fun onTaskRemoved(rootIntent: Intent?) {
val player = mediaSession?.player ?: exoPlayer
if (!player.playWhenReady || player.playbackState == ExoPlayer.STATE_ENDED) {
stopSelf()
}
}
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
}
mediaSession = null
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.audio_playback_channel),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.audio_playback_channel_description)
}
val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
}
}
private fun buildNotification(book: Book?): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(book?.title ?: getString(R.string.app_name))
.setContentText(book?.author ?: "")
.setSmallIcon(R.mipmap.ic_launcher)
.setOngoing(true)
.setSilent(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.build()
}
companion object {
const val EXTRA_BOOK = "extra_book"
private const val CHANNEL_ID = "audio_playback"
private const val NOTIFICATION_ID = 1
}
}

View file

@ -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.bookreader.audio
/**
* Placeholder for the audio player progress state.
*
* TODO: When implementing the ExoPlayer/MediaSession audio player,
* hook [org.dueattendant149.bookreader.domain.use_case.remote.SyncPlaybackProgressUseCase]
* into the playback position update flow, e.g.:
*
* ```
* val book = ...
* val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile
* val position = player.currentPosition.coerceAtLeast(0L)
* val duration = player.duration.coerceAtLeast(book.audioDuration)
* syncPlaybackProgressUseCase(book, currentFile, position, duration)
* ```
*/
data class AudioPlayerProgress(
val bookId: Int,
val currentFile: String,
val position: Long,
val duration: Long,
)

View file

@ -0,0 +1,8 @@
package org.dueattendant149.bookreader.audio
data class AudioTrack(
val fileId: String,
val title: String,
val durationMs: Long,
val order: Int,
)

View file

@ -0,0 +1,18 @@
package org.dueattendant149.bookreader.audio
/**
* Minimal Book model stub for AudioPlaybackService.
* Ported from Book's Story domain model.
*/
data class Book(
val id: Long = 0,
val title: String = "",
val author: String = "",
val remoteId: String = "",
val libraryId: String = "",
val hasAudio: Boolean = false,
val audioDuration: Long = 0L,
val audioCurrentFile: String = "",
val audioCurrentPosition: Long = 0L,
val coverUrl: String = "",
)

View file

@ -0,0 +1,222 @@
package org.dueattendant149.bookreader.bookshelf
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import timber.log.Timber
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BookshelfLibraryScreen(
viewModel: BookshelfViewModel,
onItemClick: (UnifiedItemResponse) -> Unit,
onOpenSettings: () -> Unit = {},
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
if (uiState.libraries.isEmpty()) {
viewModel.loadLibraries()
}
}
LaunchedEffect(viewModel) {
viewModel.downloadedFile.collect { uri ->
Timber.d("Downloaded book to $uri, opening in reader")
}
}
Scaffold(
topBar = {
androidx.compose.material3.CenterAlignedTopAppBar(
title = { Text("Server Library") },
actions = {
IconButton(onClick = onOpenSettings) {
Icon(Icons.Default.Settings, contentDescription = "Server settings")
}
}
)
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (uiState.isLoading && uiState.libraries.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
return@Scaffold
}
uiState.error?.let { error ->
Text(
text = error,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(16.dp)
)
}
val selected = uiState.selectedLibrary
if (selected == null) {
Text(
text = "Select a library",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxWidth()
) {
items(uiState.libraries) { library ->
LibraryCard(
library = library,
onClick = { viewModel.selectLibrary(library) }
)
}
}
} else {
Text(
text = selected.name,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(16.dp)
)
if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (uiState.downloadingItem != null) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
LazyColumn(
contentPadding = PaddingValues(16.dp),
modifier = Modifier.fillMaxSize()
) {
items(uiState.items) { item ->
ItemCard(
item = item,
isDownloading = uiState.downloadingItem?.id == item.id,
onClick = {
viewModel.downloadBook(item)
onItemClick(item)
}
)
}
}
}
}
}
}
@Composable
private fun LibraryCard(
library: LibraryResponse,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = library.name,
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${library.itemCount} items · ${library.mediaType}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
@Composable
private fun ItemCard(
item: UnifiedItemResponse,
isDownloading: Boolean,
onClick: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainerLow
)
) {
Column(modifier = Modifier.padding(20.dp)) {
Text(
text = item.title,
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
if (item.author.isNotBlank()) {
Text(
text = item.author,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
}
Text(
text = item.type,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 4.dp)
)
if (isDownloading) {
LinearProgressIndicator(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp),
)
Text(
"Downloading...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(top = 4.dp)
)
}
}
}
}

View file

@ -0,0 +1,176 @@
package org.dueattendant149.bookreader.bookshelf
import android.app.Application
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UnifiedItemResponse
import java.io.File
import javax.inject.Inject
@HiltViewModel
class BookshelfViewModel
@Inject
constructor(
application: Application,
private val repository: BookshelfApiRepository,
) : AndroidViewModel(application) {
private val _uiState = MutableStateFlow(BookshelfUiState())
val uiState: StateFlow<BookshelfUiState> = _uiState.asStateFlow()
private val _downloadedFile = MutableSharedFlow<Uri>(extraBufferCapacity = 1)
val downloadedFile: SharedFlow<Uri> = _downloadedFile.asSharedFlow()
fun loadLibraries() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
repository.getLibraries()
.onSuccess { libraries ->
_uiState.value = _uiState.value.copy(
libraries = libraries,
isLoading = false,
error = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = error.message ?: "Failed to load libraries"
)
}
}
}
fun selectLibrary(library: LibraryResponse) {
_uiState.value = _uiState.value.copy(selectedLibrary = library, items = emptyList())
loadItems(library.id)
}
fun loadItems(libraryId: String) {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
repository.getLibraryItems(libraryId)
.onSuccess { response ->
_uiState.value = _uiState.value.copy(
items = response.items,
isLoading = false,
error = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = error.message ?: "Failed to load items"
)
}
}
}
fun search(libraryId: String, query: String) {
if (query.isBlank()) {
loadItems(libraryId)
return
}
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
viewModelScope.launch {
repository.searchLibrary(libraryId, query)
.onSuccess { searchItems ->
val mapped = searchItems.map { bookItem ->
UnifiedItemResponse(
id = bookItem.id,
title = bookItem.title,
author = bookItem.author,
type = bookItem.mediaType,
mediaType = bookItem.mediaType,
libraryId = bookItem.libraryId,
coverUrl = bookItem.coverUrl
)
}
_uiState.value = _uiState.value.copy(
items = mapped,
isLoading = false,
error = null
)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
isLoading = false,
error = error.message ?: "Search failed"
)
}
}
}
fun downloadBook(item: UnifiedItemResponse) {
_uiState.value = _uiState.value.copy(downloadingItem = item)
viewModelScope.launch {
repository.downloadEbook(item.id)
.onSuccess { body ->
val file = saveToCache(item, body)
if (file != null) {
_downloadedFile.tryEmit(Uri.fromFile(file))
}
_uiState.value = _uiState.value.copy(downloadingItem = null)
}
.onFailure { error ->
_uiState.value = _uiState.value.copy(
downloadingItem = null,
error = error.message ?: "Download failed"
)
}
}
}
private fun saveToCache(item: UnifiedItemResponse, body: ResponseBody): File? {
return runCatching {
val cacheDir = getApplication<Application>().cacheDir
val dir = File(cacheDir, "bookshelf_downloads").apply { mkdirs() }
val safeName = item.title.replace(Regex("[^A-Za-z0-9._-]"), "_").take(60)
val ext = guessExtension(item)
val file = File(dir, "${safeName}_${item.id}.$ext")
file.outputStream().use { out -> body.byteStream().copyTo(out) }
file
}.getOrElse {
_uiState.value = _uiState.value.copy(
downloadingItem = null,
error = "Failed to save file: ${it.message}"
)
null
}
}
private fun guessExtension(item: UnifiedItemResponse): String = when {
item.mediaType.contains("epub", ignoreCase = true) -> "epub"
item.mediaType.contains("pdf", ignoreCase = true) -> "pdf"
item.mediaType.contains("fb2", ignoreCase = true) -> "fb2"
item.mediaType.contains("mobi", ignoreCase = true) -> "mobi"
item.mediaType.contains("azw3", ignoreCase = true) -> "azw3"
item.mediaType.contains("docx", ignoreCase = true) -> "docx"
item.mediaType.contains("odt", ignoreCase = true) -> "odt"
item.mediaType.contains("txt", ignoreCase = true) -> "txt"
item.mediaType.contains("md", ignoreCase = true) -> "md"
else -> "epub"
}
}
data class BookshelfUiState(
val libraries: List<LibraryResponse> = emptyList(),
val selectedLibrary: LibraryResponse? = null,
val items: List<UnifiedItemResponse> = emptyList(),
val isLoading: Boolean = false,
val error: String? = null,
val downloadingItem: UnifiedItemResponse? = null,
)

View file

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

View file

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

View file

@ -0,0 +1,72 @@
package org.dueattendant149.bookreader.data.remote.audiobookshelf
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.audiobookshelf.model.AbsItemResponse
import org.dueattendant149.bookreader.data.settings.ServerSettings
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Thin repository over [AudiobookshelfApiService]. Direct ABS calls for audio,
* cover and progress. bookshelf-api does not proxy audio streams.
*/
@Singleton
class AudiobookshelfRepository
@Inject
constructor(
private val clientFactory: AudiobookshelfApiClientFactory,
private val serverSettings: ServerSettings,
) {
private suspend fun client() =
serverSettings.getAbsUrl()?.let { url ->
serverSettings.getAbsToken()?.let { token ->
clientFactory.provideClient(url, token)
}
}
suspend fun getItem(itemId: String): Result<AbsItemResponse> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.getItem(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getCover(itemId: String): Result<ResponseBody> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.getCover(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun downloadBook(itemId: String): Result<ResponseBody> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.downloadBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun downloadAudioFile(
itemId: String,
fileId: String,
): Result<ResponseBody> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getProgress(itemId: String): Result<String> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.getProgress(itemId).unwrapBody() ?: "" }
}
suspend fun updateProgress(
itemId: String,
request: PlaybackProgressUpdateRequest,
): Result<String> {
val client = client() ?: return Result.failure(absNotConfigured())
return safe { client.updateProgress(itemId, request).unwrapBody() ?: "" }
}
private fun absNotConfigured() = IllegalStateException("Audiobookshelf URL/token not configured")
private inline fun <T> safe(block: () -> T): Result<T> = runCatching(block)
private fun <T> Response<T>.unwrapBody(): T? {
if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
return body()
}
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,158 @@
package org.dueattendant149.bookreader.data.remote.bookshelfapi
import okhttp3.MultipartBody
import okhttp3.RequestBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.DownloadsListResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryItemsResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.LibraryResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.SearchResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobsResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.UploadBookResponse
import org.dueattendant149.bookreader.data.settings.ServerSettings
import retrofit2.Response
import javax.inject.Inject
import javax.inject.Singleton
/**
* Thin repository over [BookshelfApiService]. Returns raw Retrofit responses
* so callers can decide how to map them to local entities.
*/
@Singleton
class BookshelfApiRepository
@Inject
constructor(
private val clientFactory: BookshelfApiClientFactory,
private val serverSettings: ServerSettings,
) {
private suspend fun client() = serverSettings.getBookshelfUrl()?.let { clientFactory.provideClient(it) }
suspend fun health(): Boolean {
val client = client() ?: return false
return runCatching { client.health().isSuccessful }.getOrDefault(false)
}
suspend fun getLibraries(): Result<List<LibraryResponse>> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getLibraries().unwrapBody().orEmpty() }
}
suspend fun getLibraryItems(libraryId: String): Result<LibraryItemsResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getLibraryItems(libraryId).unwrapBody() ?: LibraryItemsResponse() }
}
suspend fun searchLibrary(
libraryId: String,
query: String,
): Result<List<org.dueattendant149.bookreader.data.remote.bookshelfapi.model.BookItemResponse>> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.searchLibrary(libraryId, query).unwrapBody().orEmpty() }
}
suspend fun search(request: SearchRequest): Result<SearchResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.search(request).unwrapBody() ?: SearchResponse() }
}
suspend fun getBook(itemId: String): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getBook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun downloadEbook(itemId: String): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.downloadEbook(itemId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getAudioTracks(itemId: String): Result<List<org.dueattendant149.bookreader.data.remote.bookshelfapi.model.AudioTrackResponse>> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getAudioTracks(itemId).unwrapBody().orEmpty() }
}
suspend fun downloadAudioFile(
itemId: String,
fileId: String,
): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.downloadAudioFile(itemId, fileId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun updateReadingProgress(
itemId: String,
request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.ProgressUpdateRequest,
): Result<Unit> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.updateReadingProgress(itemId, request).let {} }
}
suspend fun updatePlaybackProgress(
itemId: String,
request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest,
): Result<Unit> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.updatePlaybackProgress(itemId, request).let {} }
}
suspend fun getTtsEngines(): Result<TtsEnginesResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getTtsEngines().unwrapBody() ?: TtsEnginesResponse() }
}
suspend fun getTtsVoices(engine: String? = null): Result<TtsVoicesResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getTtsVoices(engine).unwrapBody() ?: TtsVoicesResponse() }
}
suspend fun createTtsJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result<TtsJobResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.createTtsJob(request).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun getTtsJobStatus(jobId: String): Result<TtsJobResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.getTtsJobStatus(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun listTtsJobs(): Result<TtsJobsResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.listTtsJobs().unwrapBody() ?: TtsJobsResponse() }
}
suspend fun downloadTtsAudio(jobId: String): Result<okhttp3.ResponseBody> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.downloadTtsAudio(jobId).unwrapBody() ?: throw IllegalStateException("Empty body") }
}
suspend fun startDownload(request: DownloadRequest): Result<DownloadResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.startDownload(request).unwrapBody() ?: DownloadResponse() }
}
suspend fun listDownloads(): Result<DownloadsListResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.listDownloads().unwrapBody() ?: DownloadsListResponse() }
}
suspend fun uploadBook(
file: MultipartBody.Part,
bookType: RequestBody,
): Result<UploadBookResponse> {
val client = client() ?: return Result.failure(serverNotConfigured())
return safe { client.uploadBook(file, bookType).unwrapBody() ?: UploadBookResponse(success = false) }
}
private fun serverNotConfigured() = IllegalStateException("Bookshelf API URL not configured")
private inline fun <T> safe(block: () -> T): Result<T> = runCatching(block)
private fun <T> Response<T>.unwrapBody(): T? {
if (!isSuccessful) throw IllegalStateException("HTTP ${code()}: ${message()}")
return body()
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,20 @@
package org.dueattendant149.bookreader.di
import android.content.Context
import androidx.media3.exoplayer.ExoPlayer
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PlaybackModule {
@Provides
@Singleton
fun provideExoPlayer(@ApplicationContext context: Context): ExoPlayer =
ExoPlayer.Builder(context).build()
}

View file

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

View file

@ -0,0 +1,95 @@
package org.dueattendant149.bookreader.reader
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
data class ChapterItem(
val title: String,
val pageIndex: Int,
val progress: Float = 0f,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChapterDrawer(
chapters: List<ChapterItem>,
currentChapterIndex: Int,
onChapterSelected: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Chapters",
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(bottom = 12.dp)
)
LazyColumn {
itemsIndexed(chapters) { index, chapter ->
val isCurrent = index == currentChapterIndex
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onChapterSelected(index) }
.padding(vertical = 12.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = chapter.title,
style = if (isCurrent) MaterialTheme.typography.titleMedium
else MaterialTheme.typography.bodyMedium,
color = if (isCurrent) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "${(chapter.progress * 100).toInt()}%",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (isCurrent) {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "",
color = MaterialTheme.colorScheme.primary,
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
}
}

View file

@ -0,0 +1,13 @@
package org.dueattendant149.bookreader.reader
import kotlinx.serialization.Serializable
@Serializable
data class Checkpoint(
val bookId: String,
val chapterIndex: Int = 0,
val pageIndex: Int = 0,
val scrollOffset: Int = 0,
val progress: Float = 0f,
val timestamp: Long = System.currentTimeMillis(),
)

View file

@ -0,0 +1,9 @@
package org.dueattendant149.bookreader.rsvp
sealed class ReaderText {
data class Paragraph(val text: String) : ReaderText()
data class Heading(val text: String) : ReaderText()
data class Chapter(val title: String) : ReaderText()
data class Separator(val text: String = "") : ReaderText()
data class Image(val alt: String = "") : ReaderText()
}

View file

@ -0,0 +1,173 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.rsvp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Drives RSVP playback for a fixed token stream.
*
* @param scope scope used for the playback loop (typically `viewModelScope`).
*/
class RsvpEngine(private val scope: CoroutineScope) {
data class State(
val currentIndex: Int = 0,
val isPlaying: Boolean = false,
val tokens: List<RsvpToken> = emptyList(),
val wpm: Int = 350,
val pauseOnParagraphEnd: Boolean = true,
val pauseOnChapterEnd: Boolean = true,
val pauseOnLongWords: Boolean = true,
) {
val progress: Float
get() = if (tokens.isEmpty()) 0f
else currentIndex.toFloat() / tokens.lastIndex.coerceAtLeast(1)
val currentToken: RsvpToken?
get() = tokens.getOrNull(currentIndex)
val isAtEnd: Boolean
get() = tokens.isNotEmpty() && currentIndex >= tokens.lastIndex
}
private val _state = MutableStateFlow(State())
val state: StateFlow<State> = _state.asStateFlow()
private var playbackJob: Job? = null
fun setTokens(tokens: List<RsvpToken>) {
stop()
_state.value = _state.value.copy(
tokens = tokens,
currentIndex = 0,
isPlaying = false,
)
}
fun setWpm(wpm: Int) {
_state.value = _state.value.copy(wpm = wpm.coerceIn(50, 1500))
}
fun setPauseOnParagraphEnd(value: Boolean) {
_state.value = _state.value.copy(pauseOnParagraphEnd = value)
}
fun setPauseOnChapterEnd(value: Boolean) {
_state.value = _state.value.copy(pauseOnChapterEnd = value)
}
fun setPauseOnLongWords(value: Boolean) {
_state.value = _state.value.copy(pauseOnLongWords = value)
}
fun seekToIndex(index: Int) {
val s = _state.value
if (s.tokens.isEmpty()) return
val clamped = index.coerceIn(0, s.tokens.lastIndex)
_state.value = s.copy(currentIndex = clamped)
}
fun seekToProgress(progress: Float) {
val s = _state.value
if (s.tokens.isEmpty()) return
val clamped = progress.coerceIn(0f, 1f)
val idx = (clamped * s.tokens.lastIndex).toInt()
_state.value = s.copy(currentIndex = idx)
}
fun skipForward(count: Int = 10) {
seekToIndex(_state.value.currentIndex + count)
}
fun skipBackward(count: Int = 10) {
seekToIndex(_state.value.currentIndex - count)
}
fun play() {
val s = _state.value
if (s.tokens.isEmpty()) return
if (s.isAtEnd) {
seekToIndex(0)
}
if (_state.value.isPlaying) return
_state.value = _state.value.copy(isPlaying = true)
startPlaybackLoop()
}
fun pause() {
_state.value = _state.value.copy(isPlaying = false)
playbackJob?.cancel()
playbackJob = null
}
fun toggle() {
if (_state.value.isPlaying) pause() else play()
}
fun stop() {
pause()
}
private fun startPlaybackLoop() {
playbackJob?.cancel()
playbackJob = scope.launch {
while (isActive && _state.value.isPlaying) {
val snapshot = _state.value
if (snapshot.isAtEnd) {
_state.value = snapshot.copy(isPlaying = false)
return@launch
}
val token = snapshot.tokens[snapshot.currentIndex]
delay(delayMsFor(token, snapshot))
val after = _state.value
if (!after.isPlaying) return@launch
if (after.tokens.isEmpty()) return@launch
_state.value = after.copy(
currentIndex = (after.currentIndex + 1).coerceAtMost(after.tokens.lastIndex)
)
}
}
}
/**
* Compute the delay (in ms) before advancing past [token].
*
* Base = 60_000 / wpm, adjusted by:
* - per-word multiplier (long words / digits)
* - paragraph-end bonus (configurable, default +200 ms)
* - chapter-end bonus (configurable, default +400 ms)
*/
private fun delayMsFor(token: RsvpToken, snapshot: State): Long {
val wpm = snapshot.wpm.coerceAtLeast(50)
val baseMs = 60_000.0 / wpm
var ms = baseMs * token.multiplier
if (token.isParagraphEnd && snapshot.pauseOnParagraphEnd) {
ms += 200.0
}
if (token.isChapterEnd && snapshot.pauseOnChapterEnd) {
ms += 400.0
}
if (token.multiplier > 1.2f && snapshot.pauseOnLongWords) {
// multiplier already applied above; keep it
} else if (token.multiplier > 1.2f && !snapshot.pauseOnLongWords) {
ms = baseMs
}
return ms.toLong().coerceAtLeast(20L)
}
}

View file

@ -0,0 +1,54 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.rsvp
import androidx.compose.runtime.Immutable
/**
* One word/segment in the RSVP stream.
*
* @param word the original word as it appears in the book.
* @param pivotIndex index of the optimal recognition point within [word];
* characters before are [prefix], the pivot is [pivot] char, after are [suffix].
* @param isParagraphEnd true if the token is the last word of a paragraph (longer pause).
* @param isChapterEnd true if the token follows a chapter heading.
* @param multiplier duration multiplier (1.0 baseline) for per-word pacing.
*/
@Immutable
data class RsvpToken(
val word: String,
val pivotIndex: Int,
val isParagraphEnd: Boolean,
val isChapterEnd: Boolean,
val multiplier: Float = 1f,
) {
val prefix: String
get() = word.substring(0, pivotIndex)
val pivot: String
get() = word.substring(pivotIndex, pivotIndex + 1)
val suffix: String
get() = word.substring(pivotIndex + 1)
}
/**
* Optimal recognition point (ORP) lookup for word lengths 1..13+.
* Indices are 0-based positions of the focal character.
* Values follow the heuristic table from Spritz/speed-reading literature:
* 10, 20, 31, 41, 51, 62, 72, 82, 92, 103, 113, 123, 133.
*/
object OrpTable {
private val TABLE = intArrayOf(0, 0, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3)
fun pivotFor(word: String): Int {
val len = word.length
if (len == 0) return 0
if (len <= TABLE.size) return TABLE[len - 1]
return (len * 0.3f).toInt().coerceIn(1, len - 1)
}
}

View file

@ -0,0 +1,136 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.rsvp
import org.dueattendant149.bookreader.rsvp.ReaderText
/**
* Converts a list of [ReaderText] blocks (as loaded by the Book's Story reader)
* into a flat list of [RsvpToken]s suitable for RSVP playback.
*
* - Skips [ReaderText.Chapter], [ReaderText.Separator], [ReaderText.Image].
* - Marks the last word of each paragraph (blank line) as `isParagraphEnd = true`.
* - Marks the first word after a chapter heading as `isChapterEnd = true`.
* - Strips punctuation-only tokens and empty whitespace tokens.
*/
object RsvpTokenizer {
/**
* Tokenize a list of [ReaderText] blocks (as produced by the reader parser).
*/
fun tokenizeReaderText(blocks: List<ReaderText>): List<RsvpToken> {
val tokens = ArrayList<RsvpToken>(blocks.size * 8)
var lastWasParagraphEnd = false
var pendingChapterEnd = false
for (block in blocks) {
when (block) {
is ReaderText.Chapter -> {
pendingChapterEnd = true
lastWasParagraphEnd = false
}
is ReaderText.Paragraph -> {
val raw = block.text
if (raw.isBlank()) {
lastWasParagraphEnd = true
} else {
tokenizeLine(
line = raw,
isParagraphEnd = lastWasParagraphEnd,
isChapterEnd = pendingChapterEnd,
out = tokens,
)
lastWasParagraphEnd = false
pendingChapterEnd = false
}
}
is ReaderText.Heading -> {
val raw = block.text
if (raw.isNotBlank()) {
tokenizeLine(
line = raw,
isParagraphEnd = lastWasParagraphEnd,
isChapterEnd = pendingChapterEnd,
out = tokens,
)
lastWasParagraphEnd = false
pendingChapterEnd = false
}
}
is ReaderText.Separator -> lastWasParagraphEnd = true
is ReaderText.Image -> Unit
}
}
return tokens
}
private fun tokenizeLine(
line: String,
isParagraphEnd: Boolean,
isChapterEnd: Boolean,
out: MutableList<RsvpToken>,
) {
val words = line.split(WORD_SPLIT_REGEX).filter { it.isNotBlank() }
if (words.isEmpty()) return
val lastIndex = words.lastIndex
for ((idx, word) in words.withIndex()) {
val cleaned = cleanWord(word)
if (cleaned.isEmpty()) continue
val pivot = OrpTable.pivotFor(cleaned)
val paragraphEnd = isParagraphEnd && idx == lastIndex
val multiplier = computeMultiplier(cleaned)
out.add(
RsvpToken(
word = cleaned,
pivotIndex = pivot,
isParagraphEnd = paragraphEnd,
isChapterEnd = isChapterEnd && idx == 0,
multiplier = multiplier,
)
)
}
}
private val WORD_SPLIT_REGEX = Regex("\\s+")
private fun cleanWord(raw: String): String {
val sb = StringBuilder(raw.length)
var i = 0
val len = raw.length
while (i < len) {
val c = raw[i]
if (c.isLetterOrDigit() || c == '-' || c == '\'') {
sb.append(c)
}
i++
}
return sb.toString()
}
/**
* Per-word pacing multiplier.
*
* - Long words (>9 chars) get +30% time for recognition.
* - Numbers/digits get +20% time.
* - Baseline 1.0 for normal words.
*/
private fun computeMultiplier(word: String): Float {
val len = word.length
val base = when {
len > 9 -> 1.3f
len > 6 -> 1.1f
else -> 1.0f
}
val hasDigit = word.any { it.isDigit() }
return if (hasDigit) base + 0.2f else base
}
}

View file

@ -0,0 +1,14 @@
package org.dueattendant149.bookreader.tts
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
import okhttp3.ResponseBody
interface RemoteTtsRepository {
suspend fun fetchEngines(): Result<TtsEnginesResponse>
suspend fun fetchVoices(engine: String? = null): Result<TtsVoicesResponse>
suspend fun createJob(request: org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest): Result<TtsJobResponse>
suspend fun getJob(jobId: String): Result<TtsJobResponse>
suspend fun downloadAudio(jobId: String): Result<ResponseBody>
}

View file

@ -0,0 +1,23 @@
package org.dueattendant149.bookreader.tts
import okhttp3.ResponseBody
import org.dueattendant149.bookreader.data.remote.bookshelfapi.BookshelfApiRepository
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsCreateRequest
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsEnginesResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsJobResponse
import org.dueattendant149.bookreader.data.remote.bookshelfapi.model.TtsVoicesResponse
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RemoteTtsRepositoryImpl
@Inject
constructor(
private val bookshelfApi: BookshelfApiRepository,
) : RemoteTtsRepository {
override suspend fun fetchEngines(): Result<TtsEnginesResponse> = bookshelfApi.getTtsEngines()
override suspend fun fetchVoices(engine: String?): Result<TtsVoicesResponse> = bookshelfApi.getTtsVoices(engine)
override suspend fun createJob(request: TtsCreateRequest): Result<TtsJobResponse> = bookshelfApi.createTtsJob(request)
override suspend fun getJob(jobId: String): Result<TtsJobResponse> = bookshelfApi.getTtsJobStatus(jobId)
override suspend fun downloadAudio(jobId: String): Result<ResponseBody> = bookshelfApi.downloadTtsAudio(jobId)
}

View file

@ -0,0 +1,50 @@
/*
* Book's Story free and open-source Material You eBook reader.
* Copyright (C) 2024-2026 Acclorite
* SPDX-License-Identifier: GPL-3.0-only
*/
package org.dueattendant149.bookreader.tts
data class TtsEngine(
val id: String,
val name: String,
val supportsStreaming: Boolean,
val supportsCloning: Boolean,
val maxTextLength: Int,
val needsNetwork: Boolean,
)
data class TtsVoice(
val id: String,
val name: String,
val language: String,
val engine: String,
val gender: String,
val quality: String,
val requiresReference: Boolean,
)
data class TtsJob(
val jobId: String,
val bookId: String,
val title: String,
val author: String,
val engine: String,
val voiceId: String,
val speed: Double,
val status: String,
val progress: Double,
val currentChapter: String,
val totalChapters: Int,
val completedChapters: Int,
val outputPath: String,
val error: String,
val createdAt: Double,
val startedAt: Double,
val completedAt: Double,
) {
val isCompleted: Boolean get() = status == "completed"
val isFailed: Boolean get() = status == "failed" || status == "error"
val isRunning: Boolean get() = status == "running" || status == "queued" || status == "pending"
}

View file

@ -0,0 +1,35 @@
package org.dueattendant149.bookreader.work
import android.content.Context
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
/**
* Stub for CacheDownloadWorker. No-op until full Book's Story data layer is ported.
*/
class CacheDownloadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = Result.success()
}
/**
* Stub for ProgressSyncWorker. No-op until full Book's Story data layer is ported.
*/
class ProgressSyncWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = Result.success()
}
/**
* Stub for TtsDownloadWorker. No-op until full Book's Story data layer is ported.
*/
class TtsDownloadWorker(
appContext: Context,
params: WorkerParameters,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result = Result.success()
}

View file

@ -160,8 +160,6 @@ private fun ImportedFile.toImportedBookFile(): ImportedBookFile {
)
}
expect fun currentTimestamp(): Long
fun String.toFileType(): FileType {
return SharedFileCapabilities.fileTypeForName(this)
}

View file

@ -19,7 +19,10 @@ const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData"
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
const val LOCAL_FOLDER_SIDECAR_HASH_PREFIX = "book_"
internal expect fun localFolderSyncSha256ShortHex(value: String): String
fun localFolderSyncSha256ShortHex(value: String): String {
val bytes = java.security.MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }.take(12)
}
fun localFolderSyncSidecarStem(bookId: String): String {
return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId)

View file

@ -0,0 +1,3 @@
package org.dueattendant149.bookreader.shared
fun currentTimestamp(): Long = System.currentTimeMillis()

Some files were not shown because too many files have changed in this diff Show more