* Add performance and stylus debugging logs * Refactor and decouple UI models from `MainViewModel` * Refactor library state management and projection logic * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Implement desktop shell using Compose Multiplatform * Introduce ReaderEngine and enhance EPUB reader features in windows app * Move core paginated reader logic to a Kotlin Multiplatform `shared` module and introduce experimental desktop support. * Implement PDF rendering and text extraction for desktop using Pdfium * Add `NonReaderScreens.kt` and UI dependencies * Refactor and centralize library state management and models to improve cross-platform consistency * Implement JSON persistence for desktop library and enhance library management features including shelf CRUD, tagging, and metadata editing * Implement PDF annotation system and enhanced zoom controls for the desktop viewer * Implement WebView-based EPUB rendering for desktop using CEF and embedded resources * Optimize UI state projection, navigation state handling, and main screen pager performance * Implement Bring Your Own Key (BYOK) support for AI features in OSS version * Support Gemini-based Cloud TTS with BYOK support for OSS builds * Refactor table cell image sizing in `PaginatedReader` and improve `MobiParser` native library loading and error handling. * crash fixes * Enhance navigation stability with lifecycle-aware safety checks and update `navigation-compose` to 2.9.6 * Implement dynamic bottom padding for the page info bar to account for device rounded corners * Implement bidirectional jump history navigation and replace the jump-back pill with a dedicated `PdfJumpHistoryBar` * Optimize PDF tiling performance and refine pan-and-fling gesture handling * Implement customizable toolbars with drag-and-drop reordering and placement for PDF and EPUB readers * Updated UI for customize toolbar * Refine drag-and-drop reordering and section assignment for PDF and EPUB reader controls * restructure PDF viewer UI component hierarchy to fix verifier crash * Implement separate text dimming factors for light and dark themes * Synchronize Pdfium access and improve resource lifecycle safety across Kotlin and native layers * Enhance image alignment in paginated and EPUB readers through anchor detection and style-based positioning * Centralize file type resolution logic and implement HTML sanitization during import * Introduce vertical margin customization and configurable progress bar positioning * texture support in epub reader * Enhance TTS session management, progress tracking, and diagnostic logging * Optimize library state projection and folder synchronization performance by refactoring collection lookups and refining metadata extraction logic. * Refine TTS page mapping for PDF and overhaul TTS control UI * Implement natural session completion logic in `TtsPlaybackManager` for cloud tts * Replace Snackbar with `CustomTopBanner` for notifications in `PdfViewerScreen` * Refine TTS playback continuity across PDF pages and improve state management for session transitions * Implement global texture transparency and enhance textured theme support across PDF and EPUB readers. * Update reader themes and improve texture rendering in page animations, EPUB UI, and immersive mode * Add Support Project screen * Optimize library performance via projection caching, batch database updates, and scoped folder synchronization. * Enhance folder synchronization with fallback query mechanisms and refactor annotation sidecar importing logic * Bump version to 1.0.47 (51)
416 lines
16 KiB
Kotlin
416 lines
16 KiB
Kotlin
/*
|
|
* 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
|
|
*/
|
|
package com.aryan.reader.epubreader
|
|
|
|
import android.content.Context
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import timber.log.Timber
|
|
import androidx.compose.material3.AlertDialog
|
|
import androidx.compose.material3.Icon
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.TextButton
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.res.painterResource
|
|
import androidx.compose.ui.res.stringResource
|
|
import androidx.compose.ui.text.style.TextAlign
|
|
import com.aryan.reader.AiDefinitionPopup
|
|
import com.aryan.reader.AiFeature
|
|
import com.aryan.reader.AiDefinitionResult
|
|
import com.aryan.reader.AiHubBottomSheet
|
|
import com.aryan.reader.BuildConfig
|
|
import com.aryan.reader.R
|
|
import com.aryan.reader.SummarizationResult
|
|
import com.aryan.reader.SummaryCacheManager
|
|
import com.aryan.reader.callByokTextAi
|
|
import com.aryan.reader.epub.EpubBook
|
|
import com.aryan.reader.fetchRecap
|
|
import com.aryan.reader.paginatedreader.IPaginator
|
|
import com.aryan.reader.summarizationUrl
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.delay
|
|
import kotlinx.coroutines.withContext
|
|
import org.json.JSONObject
|
|
import org.jsoup.Jsoup
|
|
import java.io.File
|
|
import java.net.HttpURLConnection
|
|
import java.net.URL
|
|
|
|
/**
|
|
* Handles the raw network streaming for book content summarization.
|
|
*/
|
|
suspend fun summarizeBookContent(
|
|
content: String,
|
|
context: Context,
|
|
authToken: String?,
|
|
onUsageReceived: (cost: Double?, freeRemaining: Int?) -> Unit = { _, _ -> },
|
|
onUpdate: (String) -> Unit,
|
|
onError: (String) -> Unit,
|
|
onFinish: () -> Unit
|
|
) {
|
|
if (content.isBlank()) {
|
|
onError("The book content is empty.")
|
|
onFinish()
|
|
return
|
|
}
|
|
Timber.d("Starting summarization for content of length: ${content.length}")
|
|
|
|
@Suppress("KotlinConstantConditions")
|
|
if (BuildConfig.FLAVOR == "oss") {
|
|
if (BuildConfig.IS_OFFLINE) {
|
|
onError("AI features are unavailable in the offline OSS build.")
|
|
onFinish()
|
|
return
|
|
}
|
|
callByokTextAi(
|
|
context = context,
|
|
feature = AiFeature.SUMMARIZE,
|
|
systemInstruction = "You are an expert in analyzing written content. Provide a concise, easy-to-read summary of the provided chapter. Identify the main ideas, plot points, and themes. Do not add a preamble like 'Here is the summary:'",
|
|
userPrompt = content,
|
|
temperature = 0.2,
|
|
maxTokens = 8192,
|
|
onUpdate = onUpdate,
|
|
onError = onError
|
|
)
|
|
onFinish()
|
|
return
|
|
}
|
|
|
|
withContext(Dispatchers.IO) {
|
|
var connection: HttpURLConnection? = null
|
|
try {
|
|
val url = URL(summarizationUrl)
|
|
connection = url.openConnection() as HttpURLConnection
|
|
connection.requestMethod = "POST"
|
|
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
|
|
connection.setRequestProperty("Accept", "application/json")
|
|
if (authToken != null) {
|
|
connection.setRequestProperty("Authorization", "Bearer $authToken")
|
|
}
|
|
connection.connectTimeout = 15000
|
|
connection.readTimeout = 120000
|
|
connection.doOutput = true
|
|
connection.doInput = true
|
|
|
|
val jsonPayload = JSONObject().apply {
|
|
put("content_type", "text")
|
|
put("data", content)
|
|
}
|
|
connection.outputStream.use { os ->
|
|
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
|
|
}
|
|
|
|
val responseCode = connection.responseCode
|
|
|
|
if (responseCode == 402) {
|
|
onError("INSUFFICIENT_CREDITS")
|
|
onFinish()
|
|
return@withContext
|
|
}
|
|
|
|
if (responseCode == HttpURLConnection.HTTP_OK) {
|
|
var hasReceivedData = false
|
|
connection.inputStream.bufferedReader(Charsets.UTF_8).use { reader ->
|
|
var line: String?
|
|
while (reader.readLine().also { line = it } != null) {
|
|
try {
|
|
val jsonResponse = JSONObject(line!!)
|
|
|
|
val cost = if (jsonResponse.has("cost_deducted")) jsonResponse.optDouble("cost_deducted", -1.0) else -1.0
|
|
val freeRemaining = jsonResponse.optInt("free_summaries_remaining", -1)
|
|
if (cost > -1.0 || freeRemaining > -1) {
|
|
val finalCost = if (cost > -1.0) cost else null
|
|
val finalRemaining = if (freeRemaining > -1) freeRemaining else null
|
|
onUsageReceived(finalCost, finalRemaining)
|
|
}
|
|
|
|
jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let {
|
|
onUpdate(it)
|
|
hasReceivedData = true
|
|
}
|
|
jsonResponse.optString("error").takeIf { it.isNotEmpty() }?.let {
|
|
onError(it)
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.w(e, "Could not parse stream line: $line")
|
|
}
|
|
}
|
|
}
|
|
if (!hasReceivedData) {
|
|
onError("Failed to parse summary from server response.")
|
|
}
|
|
} else {
|
|
val errorBody = try {
|
|
connection.errorStream?.bufferedReader()?.use { it.readText() }
|
|
} catch (_: Exception) { null }
|
|
val errorDetail = try {
|
|
JSONObject(errorBody.toString()).getString("detail")
|
|
} catch (_: Exception) { "Could not fetch summary." }
|
|
onError("Error: $responseCode. $errorDetail")
|
|
}
|
|
} catch (e: Exception) {
|
|
Timber.e(e, "Network error during summarization: ${e.message}")
|
|
onError("Network error. Please check connection and server status.")
|
|
} finally {
|
|
connection?.disconnect()
|
|
onFinish()
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Orchestrates the logic for generating a Story Recap.
|
|
* Fetches past summaries from cache/network and combines with current context.
|
|
*/
|
|
suspend fun executeRecapLogic(
|
|
authToken: String?,
|
|
epubBook: EpubBook,
|
|
chapterIndex: Int,
|
|
characterLimit: Int,
|
|
summaryCacheManager: SummaryCacheManager,
|
|
paginator: IPaginator?,
|
|
context: Context,
|
|
onProgressUpdate: (String) -> Unit,
|
|
onResultUpdate: (String) -> Unit,
|
|
onCostReceived: (Double?) -> Unit = {},
|
|
onError: (String) -> Unit,
|
|
onFinish: () -> Unit
|
|
) {
|
|
Timber.d("executeRecapLogic called. ChapterIndex: $chapterIndex, CharLimit: $characterLimit")
|
|
|
|
val pastSummaries = mutableListOf<String>()
|
|
val chapters = epubBook.chapters
|
|
|
|
// 1. Fetch Past Summaries
|
|
for (i in 0 until chapterIndex) {
|
|
onProgressUpdate("Analyzing Chapter ${i + 1}...")
|
|
|
|
val cached = summaryCacheManager.getSummary(epubBook.title, i)
|
|
if (cached != null) {
|
|
pastSummaries.add(cached)
|
|
} else {
|
|
val textToSummarize = paginator?.getPlainTextForChapter(i) ?: withContext(Dispatchers.IO) {
|
|
try {
|
|
val chapter = chapters[i]
|
|
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
|
|
val doc = Jsoup.parse(File(fullPath), "UTF-8")
|
|
doc.body().text()
|
|
} catch (_: Exception) { "" }
|
|
}
|
|
|
|
if (textToSummarize.length > 100) {
|
|
val sb = StringBuilder()
|
|
val latch = kotlinx.coroutines.CompletableDeferred<Boolean>()
|
|
|
|
summarizeBookContent(
|
|
content = textToSummarize,
|
|
context = context,
|
|
authToken = authToken,
|
|
onUsageReceived = { cost, _ ->
|
|
Timber.i("[AI-Billing] Background past chapter summary cost: $cost credits")
|
|
},
|
|
onUpdate = { sb.append(it) },
|
|
onError = {
|
|
Timber.e("Failed to summarize Ch $i for recap: $it")
|
|
latch.complete(false)
|
|
},
|
|
onFinish = {
|
|
latch.complete(true)
|
|
val summary = sb.toString()
|
|
if (summary.isNotBlank()) {
|
|
val chapterTitle = chapters.getOrNull(i)?.title ?: "Chapter ${i + 1}"
|
|
summaryCacheManager.saveSummary(epubBook.title, i, chapterTitle, summary)
|
|
pastSummaries.add(summary)
|
|
}
|
|
}
|
|
)
|
|
|
|
val success = latch.await()
|
|
if (success && sb.isNotEmpty()) {
|
|
val summary = sb.toString()
|
|
|
|
val chapterTitle = chapters.getOrNull(i)?.title ?: "Chapter ${i + 1}"
|
|
|
|
summaryCacheManager.saveSummary(
|
|
bookTitle = epubBook.title,
|
|
chapterIndex = i,
|
|
chapterTitle = chapterTitle,
|
|
summary = summary
|
|
)
|
|
pastSummaries.add(summary)
|
|
}
|
|
}
|
|
}
|
|
// Small delay to prevent rate limits
|
|
if (pastSummaries.isNotEmpty() && !summaryCacheManager.hasSummary(epubBook.title, i)) {
|
|
delay(500)
|
|
}
|
|
}
|
|
|
|
// 2. Get Current Context
|
|
onProgressUpdate("Reading current position...")
|
|
|
|
val currentChapterText = paginator?.getPlainTextForChapter(chapterIndex)
|
|
?: withContext(Dispatchers.IO) {
|
|
try {
|
|
Jsoup.parse(File("${epubBook.extractionBasePath}/${chapters[chapterIndex].htmlFilePath}"), "UTF-8").body().text()
|
|
} catch (_: Exception) { "" }
|
|
}
|
|
|
|
val endIndex = characterLimit.coerceIn(0, currentChapterText.length)
|
|
val textSoFar = currentChapterText.substring(0, endIndex)
|
|
|
|
// Fallback if text is blank
|
|
val finalContextText = if (textSoFar.isBlank() && currentChapterText.isNotEmpty()) {
|
|
currentChapterText.take(500)
|
|
} else {
|
|
textSoFar
|
|
}
|
|
|
|
onProgressUpdate("Generating Recap...")
|
|
fetchRecap(
|
|
pastSummaries = pastSummaries,
|
|
currentText = finalContextText,
|
|
context = context,
|
|
authToken = authToken,
|
|
onUpdate = { chunk -> onResultUpdate(chunk) },
|
|
onCostReceived = onCostReceived,
|
|
onError = { error -> onError(error) },
|
|
onFinish = { onFinish() }
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Container for all AI-related popups and dialogs (Summary, Recap, Definition, Upsells).
|
|
*/
|
|
@Composable
|
|
fun EpubReaderAiOverlays(
|
|
bookTitle: String,
|
|
currentChapterIndex: Int,
|
|
chapterTitle: String,
|
|
summaryCacheManager: SummaryCacheManager,
|
|
showAiHubSheet: Boolean,
|
|
summarizationResult: SummarizationResult?,
|
|
isSummarizationLoading: Boolean,
|
|
onGenerateSummary: (Boolean) -> Unit,
|
|
recapResult: SummarizationResult?,
|
|
isRecapLoading: Boolean,
|
|
onGenerateRecap: () -> Unit,
|
|
onDismissAiHub: () -> Unit,
|
|
onClearSummary: () -> Unit = {},
|
|
onClearRecap: () -> Unit = {},
|
|
showSummarizationUpsellDialog: Boolean,
|
|
onDismissSummarizationUpsell: () -> Unit,
|
|
showAiDefinitionPopup: Boolean,
|
|
selectedTextForAi: String?,
|
|
aiDefinitionResult: AiDefinitionResult?,
|
|
isAiDefinitionLoading: Boolean,
|
|
onDismissAiDefinition: () -> Unit,
|
|
showDictionaryUpsellDialog: Boolean,
|
|
onDismissDictionaryUpsell: () -> Unit,
|
|
onNavigateToPro: () -> Unit,
|
|
isTtsSessionActive: Boolean,
|
|
onOpenExternalDictionary: (String) -> Unit,
|
|
getAuthToken: suspend () -> String?,
|
|
credits: Int,
|
|
isProUser: Boolean
|
|
) {
|
|
if (showAiHubSheet) {
|
|
AiHubBottomSheet(
|
|
bookTitle = bookTitle,
|
|
currentChapterIndex = currentChapterIndex,
|
|
chapterTitle = chapterTitle,
|
|
summaryCacheManager = summaryCacheManager,
|
|
summarizationResult = summarizationResult,
|
|
isSummarizationLoading = isSummarizationLoading,
|
|
onGenerateSummary = onGenerateSummary,
|
|
recapResult = recapResult,
|
|
isRecapLoading = isRecapLoading,
|
|
onGenerateRecap = onGenerateRecap,
|
|
onDismiss = onDismissAiHub,
|
|
onClearSummary = onClearSummary,
|
|
onClearRecap = onClearRecap,
|
|
isMainTtsActive = isTtsSessionActive,
|
|
getAuthToken = getAuthToken,
|
|
credits = credits,
|
|
isProUser = isProUser
|
|
)
|
|
}
|
|
|
|
if (showSummarizationUpsellDialog) {
|
|
AlertDialog(
|
|
onDismissRequest = onDismissSummarizationUpsell,
|
|
icon = { Icon(painter = painterResource(id = R.drawable.summarize), contentDescription = null) },
|
|
title = {
|
|
Text(
|
|
text = stringResource(R.string.ai_unlock_summarization),
|
|
modifier = Modifier.fillMaxWidth(),
|
|
textAlign = TextAlign.Center
|
|
)
|
|
},
|
|
text = {
|
|
Text(
|
|
text = stringResource(R.string.ai_unlock_summarization_desc),
|
|
)
|
|
},
|
|
confirmButton = {
|
|
TextButton(onClick = {
|
|
onDismissSummarizationUpsell()
|
|
onNavigateToPro()
|
|
}) { Text(stringResource(R.string.action_learn_more)) }
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = onDismissSummarizationUpsell) { Text(stringResource(R.string.action_not_now)) }
|
|
}
|
|
)
|
|
}
|
|
|
|
if (showAiDefinitionPopup) {
|
|
AiDefinitionPopup(
|
|
word = selectedTextForAi,
|
|
result = aiDefinitionResult,
|
|
isLoading = isAiDefinitionLoading,
|
|
onDismiss = onDismissAiDefinition,
|
|
isMainTtsActive = isTtsSessionActive,
|
|
onOpenExternalDictionary = {
|
|
selectedTextForAi?.let { text -> onOpenExternalDictionary(text) }
|
|
},
|
|
getAuthToken = getAuthToken
|
|
)
|
|
}
|
|
|
|
if (showDictionaryUpsellDialog) {
|
|
AlertDialog(
|
|
onDismissRequest = onDismissDictionaryUpsell,
|
|
icon = { Icon(painter = painterResource(id = R.drawable.ai), contentDescription = null) },
|
|
title = { Text(stringResource(R.string.ai_unlock_smart_dict)) },
|
|
text = { Text(stringResource(R.string.ai_unlock_smart_dict_desc)) },
|
|
confirmButton = {
|
|
TextButton(onClick = {
|
|
onDismissDictionaryUpsell()
|
|
onNavigateToPro()
|
|
}) { Text(stringResource(R.string.action_learn_more)) }
|
|
},
|
|
dismissButton = {
|
|
TextButton(onClick = onDismissDictionaryUpsell) { Text(stringResource(R.string.action_not_now)) }
|
|
}
|
|
)
|
|
}
|
|
}
|