Compare commits
18 commits
5f64f3d722
...
8e94eb3850
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e94eb3850 | ||
|
|
fb6403c4a9 | ||
|
|
1dc3bf2fcb | ||
|
|
cacfe2c7c4 | ||
|
|
f7bffd0b06 | ||
|
|
1c0eca423d | ||
|
|
d0ceeef24f | ||
|
|
fef33afe69 | ||
|
|
1666ae0fe2 | ||
|
|
d999e9df77 | ||
|
|
a8ad2bf900 | ||
|
|
907d7c8d24 | ||
|
|
88113d9de6 | ||
|
|
9e30933077 | ||
|
|
0d9439e8c3 | ||
|
|
ab9a2dcfeb | ||
|
|
83e4b420d4 | ||
|
|
73f16be140 |
385 changed files with 2964 additions and 53192 deletions
36
AGENTS.md
36
AGENTS.md
|
|
@ -62,15 +62,39 @@
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
```bash
|
```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
|
./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
|
## Status
|
||||||
|
|
||||||
- Phase 0: clone + package rename — completed
|
- Phase 0: clone + package rename — completed
|
||||||
- Phase 1: KMP → Android-only — pending
|
- Phase 1: KMP → Android-only — completed
|
||||||
- Phase 2: backend swap (bookshelf-api + ABS) — pending
|
- Phase 2: backend swap (bookshelf-api + ABS) — completed
|
||||||
- Phase 3: UI redesign (Myne-style) — pending
|
- Phase 2.1: bookshelf-api + ABS API services/models, Hilt DI, Retrofit/OkHttp
|
||||||
- Phase 4: features from Book's Story (audio/RSVP/search/TTS/cache) — pending
|
- Phase 2.2: BookshelfViewModel + BookshelfLibraryScreen + nav entry
|
||||||
- Phase 5: reader engine integration — pending
|
- Phase 2.3: ServerSettingsScreen (URL/token config)
|
||||||
- Phase 6: build, test, deploy — pending
|
- 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
|
||||||
|
|
@ -11,6 +11,7 @@ plugins {
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.kotlin.ksp)
|
alias(libs.plugins.kotlin.ksp)
|
||||||
|
alias(libs.plugins.hilt)
|
||||||
id("com.diffplug.spotless") version "8.2.1"
|
id("com.diffplug.spotless") version "8.2.1"
|
||||||
alias(libs.plugins.kover)
|
alias(libs.plugins.kover)
|
||||||
}
|
}
|
||||||
|
|
@ -46,7 +47,9 @@ fun configuredAppLocaleTags(): Set<String> {
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
jvmToolchain(21)
|
jvmToolchain {
|
||||||
|
languageVersion.set(JavaLanguageVersion.of(21))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
@ -64,11 +67,13 @@ android {
|
||||||
.map { it.toAndroidResourceConfiguration() }
|
.map { it.toAndroidResourceConfiguration() }
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
/*
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
cppFlags += ""
|
cppFlags += ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
buildConfigField("boolean", "IS_PRO", "false")
|
buildConfigField("boolean", "IS_PRO", "false")
|
||||||
buildConfigField("boolean", "IS_OFFLINE", "false")
|
buildConfigField("boolean", "IS_OFFLINE", "false")
|
||||||
}
|
}
|
||||||
|
|
@ -162,12 +167,14 @@ android {
|
||||||
singleVariant("release") {
|
singleVariant("release") {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
cmake {
|
cmake {
|
||||||
path = file("src/main/cpp/CMakeLists.txt")
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
version = "3.22.1"
|
version = "3.22.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
testOptions {
|
testOptions {
|
||||||
unitTests.isReturnDefaultValues = true
|
unitTests.isReturnDefaultValues = true
|
||||||
unitTests.all {
|
unitTests.all {
|
||||||
|
|
@ -211,8 +218,6 @@ kover {
|
||||||
//noinspection UseTomlInstead
|
//noinspection UseTomlInstead
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|
||||||
implementation(project(":shared"))
|
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.lifecycle.runtime.ktx)
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
@ -224,6 +229,16 @@ dependencies {
|
||||||
implementation(libs.androidx.material3.window.size.class1.android)
|
implementation(libs.androidx.material3.window.size.class1.android)
|
||||||
implementation(libs.androidx.credentials)
|
implementation(libs.androidx.credentials)
|
||||||
|
|
||||||
|
// Hilt
|
||||||
|
implementation(libs.hilt.android)
|
||||||
|
ksp(libs.hilt.compiler)
|
||||||
|
implementation(libs.hilt.navigation.compose)
|
||||||
|
|
||||||
|
// Networking
|
||||||
|
implementation(libs.retrofit)
|
||||||
|
implementation(libs.retrofit.kotlinx.serialization)
|
||||||
|
implementation(libs.okhttp.logging)
|
||||||
|
|
||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
androidTestImplementation(libs.androidx.espresso.core)
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.unit.dp
|
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.Lifecycle
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
|
@ -59,6 +61,7 @@ import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
|
import org.dueattendant149.bookreader.bookshelf.BookshelfLibraryScreen
|
||||||
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
|
import org.dueattendant149.bookreader.epubreader.EpubReaderScreen
|
||||||
import org.dueattendant149.bookreader.feedback.FeedbackScreen
|
import org.dueattendant149.bookreader.feedback.FeedbackScreen
|
||||||
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
|
import org.dueattendant149.bookreader.feedback.SupportProjectScreen
|
||||||
|
|
@ -79,6 +82,8 @@ object AppDestinations {
|
||||||
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
||||||
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
|
const val AI_SETTINGS_SCREEN_ROUTE = "ai_settings_screen_route"
|
||||||
const val SETTINGS_SCREEN_ROUTE = "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(
|
fun shouldInterceptAppNavBack(
|
||||||
|
|
@ -427,6 +432,34 @@ fun AppNavigation(
|
||||||
onBackClick = { navController.popBackStackIfReady() }
|
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(
|
AnimatedVisibility(
|
||||||
|
|
|
||||||
|
|
@ -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})"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 ?: ""
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -24,9 +24,11 @@ import android.webkit.WebView
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.ImageLoaderFactory
|
import coil.ImageLoaderFactory
|
||||||
import coil.decode.SvgDecoder
|
import coil.decode.SvgDecoder
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
|
import org.dueattendant149.bookreader.paginatedreader.SvgStringFetcher
|
||||||
import timber.log.Timber // Add this
|
import timber.log.Timber // Add this
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
class MyApplication : Application(), ImageLoaderFactory {
|
class MyApplication : Application(), ImageLoaderFactory {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
package org.dueattendant149.bookreader.opds
|
|
||||||
|
|
||||||
typealias OpdsParser = org.dueattendant149.bookreader.shared.opds.SharedOpdsParser
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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)
|
|
||||||
}
|
|
||||||
|
|
@ -783,20 +783,7 @@ class OpdsStreamDocumentWrapper(
|
||||||
) : ReaderDocument {
|
) : ReaderDocument {
|
||||||
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
|
private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() }
|
||||||
|
|
||||||
private val catalog = catalogId?.let {
|
private val client = okhttp3.OkHttpClient.Builder().build()
|
||||||
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 fun createErrorPageBytes(): ByteArray {
|
private fun createErrorPageBytes(): ByteArray {
|
||||||
val bitmap = createBitmap(800, 1200)
|
val bitmap = createBitmap(800, 1200)
|
||||||
|
|
@ -827,18 +814,7 @@ class OpdsStreamDocumentWrapper(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val streamCatalog = catalog
|
val finalUrlTemplate = urlTemplate
|
||||||
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 url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
|
val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString())
|
||||||
.replace("{maxWidth}", "1600")
|
.replace("{maxWidth}", "1600")
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -178,6 +178,7 @@ fun AppTheme(
|
||||||
MaterialTheme(
|
MaterialTheme(
|
||||||
colorScheme = finalColorScheme,
|
colorScheme = finalColorScheme,
|
||||||
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
|
typography = appFontFamily?.let { AppTypography.withAppFontFamily(it) } ?: AppTypography,
|
||||||
|
shapes = AppShapes,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
package org.dueattendant149.bookreader.audio
|
||||||
|
|
||||||
|
data class AudioTrack(
|
||||||
|
val fileId: String,
|
||||||
|
val title: String,
|
||||||
|
val durationMs: Long,
|
||||||
|
val order: Int,
|
||||||
|
)
|
||||||
|
|
@ -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 = "",
|
||||||
|
)
|
||||||
|
|
@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
|
@ -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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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/"
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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:
|
||||||
|
* 1→0, 2→0, 3→1, 4→1, 5→1, 6→2, 7→2, 8→2, 9→2, 10→3, 11→3, 12→3, 13→3.
|
||||||
|
*/
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -160,8 +160,6 @@ private fun ImportedFile.toImportedBookFile(): ImportedBookFile {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
expect fun currentTimestamp(): Long
|
|
||||||
|
|
||||||
fun String.toFileType(): FileType {
|
fun String.toFileType(): FileType {
|
||||||
return SharedFileCapabilities.fileTypeForName(this)
|
return SharedFileCapabilities.fileTypeForName(this)
|
||||||
}
|
}
|
||||||
|
|
@ -19,7 +19,10 @@ const val LOCAL_FOLDER_SYNC_DATA_DIR = "EpistemeSyncData"
|
||||||
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
|
const val LOCAL_FOLDER_ANNOTATION_SUFFIX = "_annotations"
|
||||||
const val LOCAL_FOLDER_SIDECAR_HASH_PREFIX = "book_"
|
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 {
|
fun localFolderSyncSidecarStem(bookId: String): String {
|
||||||
return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId)
|
return LOCAL_FOLDER_SIDECAR_HASH_PREFIX + localFolderSyncSha256ShortHex(bookId)
|
||||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue