book-reader/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
Aryan d7a9cae9e1
v1.0.47 (#279)
* 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)
2026-05-04 21:55:38 +05:30

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)) }
}
)
}
}