/* * 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 . * * 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() 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() 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)) } } ) } }