/*
* 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
*/
// EpubReaderScreen.kt
@file:OptIn(ExperimentalSerializationApi::class) @file:Suppress("VariableNeverRead",
"UnusedVariable", "Unused", "SimplifyBooleanWithConstants", "KotlinConstantConditions"
)
package com.aryan.reader.epubreader
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import android.view.RoundedCorner
import android.view.View
import android.webkit.WebView
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsEndWidth
import androidx.compose.foundation.layout.windowInsetsStartWidth
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageShader
import androidx.compose.ui.graphics.ShaderBrush
import androidx.compose.ui.graphics.TileMode
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig
import com.aryan.reader.BuiltInThemes
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
import com.aryan.reader.ReaderBrightnessEffect
import com.aryan.reader.ReaderFileInfoDialogs
import com.aryan.reader.ReaderBrightnessSheet
import com.aryan.reader.ReaderScreenOrientationEffect
import com.aryan.reader.ReaderScreenOrientationSheet
import com.aryan.reader.ReaderThemePanel
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.TtsWordReplacementsSheet
import com.aryan.reader.areReaderAiFeaturesEnabled
import com.aryan.reader.countWords
import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
import com.aryan.reader.loadReaderBrightnessSettings
import com.aryan.reader.loadReaderScreenOrientationMode
import com.aryan.reader.loadEpubRightToLeftPagination
import com.aryan.reader.loadReaderThemeId
import com.aryan.reader.loadReaderSliderToggled
import com.aryan.reader.loadReaderTextureBitmap
import com.aryan.reader.loadTtsReplacementPreferences
import com.aryan.reader.readerSliderBookmarkPosition
import com.aryan.reader.readerSliderChromeColors
import com.aryan.reader.readerSliderToggleState
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.HeaderBlock
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.paginatedreader.ListItemBlock
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.LocatorConverter
import com.aryan.reader.paginatedreader.PaginatedReaderScreen
import com.aryan.reader.paginatedreader.ParagraphBlock
import com.aryan.reader.paginatedreader.QuoteBlock
import com.aryan.reader.paginatedreader.TextContentBlock
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
import com.aryan.reader.saveReaderBrightnessSettings
import com.aryan.reader.saveReaderScreenOrientationMode
import com.aryan.reader.saveEpubRightToLeftPagination
import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.saveReaderSliderToggled
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shouldRenderReaderSlider
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import com.aryan.reader.shared.reader.ReaderJumpHistory
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
private const val AUTO_SCROLL_MIN_SPEED_KEY = "auto_scroll_min_speed"
private const val AUTO_SCROLL_MAX_SPEED_KEY = "auto_scroll_max_speed"
private const val PAGE_TURN_ANIMATION_KEY = "page_turn_animation_enabled"
private const val TTS_MODE_KEY = "tts_mode"
private const val AUTO_SCROLL_IS_LOCAL_PREFIX = "auto_scroll_is_local_"
private const val AUTO_SCROLL_LOCAL_SPEED_PREFIX = "auto_scroll_local_speed_"
private const val AUTO_SCROLL_LOCAL_MIN_PREFIX = "auto_scroll_local_min_"
private const val AUTO_SCROLL_LOCAL_MAX_PREFIX = "auto_scroll_local_max_"
private const val MUSICIAN_MODE_KEY = "musician_mode_enabled"
private const val KEEP_SCREEN_ON_KEY = "keep_screen_on_enabled"
private const val HIDDEN_TOOLS_KEY = "hidden_reader_tools"
private const val TOOL_ORDER_KEY = "reader_tool_order"
private const val BOTTOM_TOOLS_KEY = "reader_bottom_tools"
private const val HIDDEN_TOOLS_DEFAULTS_VERSION_KEY = "reader_hidden_tools_defaults_version"
private const val HIDDEN_TOOLS_DEFAULTS_VERSION = 2
private const val TTS_LOCATE_REASON_INITIAL_RESTORE = "initial_restore"
private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume"
private const val TTS_LOCATE_REASON_OVERLAY = "overlay"
private const val TAG_LINK_NAV = "LINK_NAV"
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
private const val TAG_STABLE_PAGE_NAV = "StablePageNav"
private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag"
private fun epubHighlightDiagSnippet(text: String, maxLength: Int = 80): String {
return text
.replace('\n', ' ')
.replace('\r', ' ')
.replace('\t', ' ')
.take(maxLength)
}
private fun List.withInitialChunkOverride(
startChunkIndex: Int,
initialChunk: TtsChunk?
): List {
if (initialChunk == null || startChunkIndex !in indices) return this
val existing = this[startChunkIndex]
if (
existing.text == initialChunk.text &&
existing.sourceCfi == initialChunk.sourceCfi &&
existing.startOffsetInSource == initialChunk.startOffsetInSource
) {
return this
}
return toMutableList().also { chunks ->
chunks[startChunkIndex] = initialChunk
}
}
private fun View.bottomRoundedCornerRadiusPx(): Int {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return 0
val insets = rootWindowInsets ?: return 0
return max(
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT)?.radius ?: 0,
insets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT)?.radius ?: 0
)
}
@Composable
private fun rememberBottomRoundedCornerPadding(view: View): Dp {
val density = LocalDensity.current
val configuration = LocalConfiguration.current
var radiusPx by remember(view) { mutableIntStateOf(view.bottomRoundedCornerRadiusPx()) }
DisposableEffect(
view,
configuration.orientation,
configuration.screenWidthDp,
configuration.screenHeightDp
) {
val listener = View.OnLayoutChangeListener { updatedView, _, _, _, _, _, _, _, _ ->
radiusPx = updatedView.bottomRoundedCornerRadiusPx()
}
view.addOnLayoutChangeListener(listener)
radiusPx = view.bottomRoundedCornerRadiusPx()
onDispose {
view.removeOnLayoutChangeListener(listener)
}
}
return with(density) { radiusPx.toDp() }
}
private fun saveHiddenTools(context: Context, hiddenTools: Set) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit {
putStringSet(HIDDEN_TOOLS_KEY, hiddenTools)
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
}
}
private fun loadHiddenTools(context: Context): Set {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val savedHiddenTools = prefs.getStringSet(HIDDEN_TOOLS_KEY, emptySet()).orEmpty()
val defaultsVersion = prefs.getInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, 0)
if (defaultsVersion < HIDDEN_TOOLS_DEFAULTS_VERSION) {
val migratedHiddenTools = savedHiddenTools + readerHiddenToolsIntroducedAfter(defaultsVersion)
prefs.edit {
putStringSet(HIDDEN_TOOLS_KEY, migratedHiddenTools)
putInt(HIDDEN_TOOLS_DEFAULTS_VERSION_KEY, HIDDEN_TOOLS_DEFAULTS_VERSION)
}
return migratedHiddenTools
}
return savedHiddenTools
}
private fun readerHiddenToolsIntroducedAfter(defaultsVersion: Int): Set {
return buildSet {
if (defaultsVersion < 1) add(ReaderTool.SCREEN_ORIENTATION.name)
if (defaultsVersion < 2) add(ReaderTool.BRIGHTNESS.name)
}
}
private fun saveToolOrder(context: Context, toolOrder: List) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TOOL_ORDER_KEY, toolOrder.joinToString(",") { it.name }) }
}
private fun loadToolOrder(context: Context): List {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
val savedTools = prefs.getString(TOOL_ORDER_KEY, null)
?.split(',')
?.filter { it.isNotBlank() }
?.mapNotNull { name -> ReaderTool.entries.firstOrNull { it.name == name } }
.orEmpty()
return (savedTools + defaultReaderToolOrder().filterNot { it in savedTools }).distinct()
}
private fun saveBottomTools(context: Context, bottomTools: Set) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putStringSet(BOTTOM_TOOLS_KEY, bottomTools) }
}
private fun loadBottomTools(context: Context): Set {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getStringSet(
BOTTOM_TOOLS_KEY,
defaultReaderBottomTools()
) ?: defaultReaderBottomTools()
}
private fun saveKeepScreenOn(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(KEEP_SCREEN_ON_KEY, isEnabled) }
}
private fun loadKeepScreenOn(context: Context): Boolean {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(KEEP_SCREEN_ON_KEY, false)
}
private fun saveMusicianMode(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(MUSICIAN_MODE_KEY, isEnabled) }
}
private fun loadMusicianMode(context: Context): Boolean {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(MUSICIAN_MODE_KEY, false)
}
private fun getBookIdForPrefs(title: String): String {
return title.hashCode().toString()
}
private fun saveAutoScrollLocalMode(context: Context, bookId: String, isLocal: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, isLocal) }
}
private fun loadAutoScrollLocalMode(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(AUTO_SCROLL_IS_LOCAL_PREFIX + bookId, false)
}
private fun saveAutoScrollLocalSettings(context: Context, bookId: String, speed: Float, min: Float, max: Float) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit {
putFloat(AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, speed)
putFloat(AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, min)
putFloat(AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, max)
}
}
private fun loadAutoScrollLocalSettings(context: Context, bookId: String): Triple? {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
if (!prefs.contains(AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId)) return null
val speed = prefs.getFloat(AUTO_SCROLL_LOCAL_SPEED_PREFIX + bookId, 3.0f)
val min = prefs.getFloat(AUTO_SCROLL_LOCAL_MIN_PREFIX + bookId, 0.1f)
val max = prefs.getFloat(AUTO_SCROLL_LOCAL_MAX_PREFIX + bookId, 10.0f)
return Triple(speed, min, max)
}
private fun savePageTurnAnimationSetting(context: Context, isEnabled: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(PAGE_TURN_ANIMATION_KEY, isEnabled) }
}
private fun loadPageTurnAnimationSetting(context: Context): Boolean {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PAGE_TURN_ANIMATION_KEY, false)
}
private fun saveAutoScrollMinSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putFloat(AUTO_SCROLL_MIN_SPEED_KEY, speed) }
}
private fun loadAutoScrollMinSpeed(context: Context): Float {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getFloat(AUTO_SCROLL_MIN_SPEED_KEY, 0.1f)
}
private fun saveAutoScrollMaxSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putFloat(AUTO_SCROLL_MAX_SPEED_KEY, speed) }
}
private fun loadAutoScrollMaxSpeed(context: Context): Float {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getFloat(AUTO_SCROLL_MAX_SPEED_KEY, 10.0f)
}
private fun saveAutoScrollUseSlider(context: Context, useSlider: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(AUTO_SCROLL_USE_SLIDER_KEY, useSlider) }
}
private fun loadAutoScrollUseSlider(context: Context): Boolean {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(AUTO_SCROLL_USE_SLIDER_KEY, false)
}
private fun saveTtsMode(context: Context, modeName: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, modeName) }
}
private const val PREF_USE_ONLINE_DICT = "use_online_dictionary"
private const val PREF_EXTERNAL_DICT_PKG = "external_dictionary_package"
private const val PREF_EXTERNAL_TRANSLATE_PKG = "external_translate_package"
private const val PREF_EXTERNAL_SEARCH_PKG = "external_search_package"
private fun loadUseOnlineDict(context: Context): Boolean {
@Suppress("KotlinConstantConditions") if (BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE) return false
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getBoolean(PREF_USE_ONLINE_DICT, true)
}
private fun saveUseOnlineDict(context: Context, useOnline: Boolean) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putBoolean(PREF_USE_ONLINE_DICT, useOnline) }
}
private fun loadExternalDictPackage(context: Context): String? {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_DICT_PKG, null)
}
private fun saveExternalDictPackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_DICT_PKG, packageName) }
}
private fun loadExternalTranslatePackage(context: Context): String? {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_TRANSLATE_PKG, null)
}
private fun saveExternalTranslatePackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_TRANSLATE_PKG, packageName) }
}
private fun loadExternalSearchPackage(context: Context): String? {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
return prefs.getString(PREF_EXTERNAL_SEARCH_PKG, null)
}
private fun saveExternalSearchPackage(context: Context, packageName: String) {
val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
prefs.edit { putString(PREF_EXTERNAL_SEARCH_PKG, packageName) }
}
const val PREF_READER_THEME = "reader_theme_id"
const val PREF_CUSTOM_THEMES = "custom_themes_json"
@UnstableApi
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun EpubReaderScreen(
epubBook: EpubBook,
renderMode: RenderMode,
initialLocator: Locator?,
initialCfi: String?,
initialBookmarksJson: String?,
isProUser: Boolean,
onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit,
onNavigateToPro: () -> Unit,
coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit,
customFonts: List,
onImportFont: (Uri) -> Unit,
viewModel: MainViewModel
) {
val uiState by viewModel.uiState.collectAsState()
val isReflowFile = uiState.selectedBookId?.endsWith("_reflow") == true
val originalBookId = if (isReflowFile) uiState.selectedBookId!!.removeSuffix("_reflow") else null
val onOpenOriginal: ((Int) -> Unit)? = if (originalBookId != null) {
{ currentChapter ->
val originalItem = uiState.recentFiles.find { it.bookId == originalBookId }
if (originalItem != null) {
viewModel.switchToFileSeamlessly(originalItem, currentChapter)
} else {
viewModel.showBanner("Original PDF not found.", true)
}
}
} else null
val hasValidExtractionBasePath = remember(epubBook.extractionBasePath, epubBook.chapters) {
epubBook.hasReadableExtractedContent()
}
var requestedContentRecovery by remember(epubBook.extractionBasePath, uiState.selectedBookId) {
mutableStateOf(false)
}
LaunchedEffect(hasValidExtractionBasePath, uiState.selectedBookId, uiState.selectedEpubUri) {
if (!hasValidExtractionBasePath && !requestedContentRecovery && uiState.selectedEpubUri != null) {
requestedContentRecovery = true
viewModel.recoverSelectedEpubContent()
}
}
if (!hasValidExtractionBasePath) {
val isRecovering = uiState.isLoading || (requestedContentRecovery && uiState.errorMessage == null)
val message = uiState.errorMessage ?: if (isRecovering) {
"Recovering book content..."
} else {
"Book content not found. Reopen the book to recreate its cache."
}
Box(modifier = Modifier.fillMaxSize()) {
if (isRecovering) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
Text(
text = message,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(24.dp),
color = if (uiState.errorMessage != null) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
textAlign = TextAlign.Center
)
}
return
}
EpubReaderHost(
epubBook = epubBook,
renderMode = renderMode,
initialLocator = initialLocator,
initialCfi = initialCfi,
initialBookmarksJson = initialBookmarksJson,
initialHighlightsJson = uiState.initialHighlightsJson,
isProUser = isProUser,
credits = uiState.credits,
onNavigateBack = onNavigateBack,
onSavePosition = onSavePosition,
onBookmarksChanged = onBookmarksChanged,
onHighlightsChanged = { json ->
uiState.selectedBookId?.let { id ->
viewModel.saveHighlights(id, json)
}
},
onNavigateToPro = onNavigateToPro,
coverImagePath = coverImagePath,
onRenderModeChange = onRenderModeChange,
customFonts = customFonts,
onImportFont = onImportFont,
onToggleReflow = onOpenOriginal,
onDeleteReflow = if (isReflowFile) {
{
uiState.selectedBookId?.let { id ->
viewModel.deleteBookPermanently(id) {
onNavigateBack()
}
}
}
} else null,
stableBookId = uiState.selectedBookId,
viewModel = viewModel
)
}
@Suppress("ControlFlowWithEmptyBody")
@SuppressLint("UnusedBoxWithConstraintsScope", "ObsoleteSdkInt", "LocalContextGetResourceValueCall")
@androidx.annotation.OptIn(UnstableApi::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EpubReaderHost(
epubBook: EpubBook,
renderMode: RenderMode,
initialLocator: Locator?,
initialCfi: String?,
initialBookmarksJson: String?,
initialHighlightsJson: String?,
isProUser: Boolean,
credits: Int,
onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit,
onHighlightsChanged: (highlightsJson: String) -> Unit,
onNavigateToPro: () -> Unit,
coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit,
customFonts: List,
onImportFont: (Uri) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null,
stableBookId: String? = null,
viewModel: MainViewModel
) {
val view = LocalView.current
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsState()
val window = (view.context as? Activity)?.window
val activity = context as? Activity
val scope = rememberCoroutineScope()
var readerBrightnessSettings by remember { mutableStateOf(loadReaderBrightnessSettings(context)) }
var showBrightnessSheet by remember { mutableStateOf(false) }
ReaderBrightnessEffect(window, readerBrightnessSettings)
val updateReaderBrightness: (com.aryan.reader.ReaderBrightnessSettings) -> Unit = { settings ->
readerBrightnessSettings = settings
saveReaderBrightnessSettings(context, settings)
}
fun showBanner(message: String, isError: Boolean = false, isPersistent: Boolean = false) {
viewModel.showBanner(message, isError, isPersistent)
}
DisposableEffect(window, view) {
onDispose {
window?.let {
WindowCompat.getInsetsController(it, view).show(WindowInsetsCompat.Type.systemBars())
}
}
}
val focusManager = LocalFocusManager.current
val searchFocusRequester = remember { FocusRequester() }
val containerFocusRequester = remember { FocusRequester() }
var isNavigatingToPosition by remember { mutableStateOf(false) }
var isSeamlessTransitioning by remember { mutableStateOf(false) }
var showInsufficientCreditsDialog by remember { mutableStateOf(false) }
var showFileInfoDialog by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
var isFastScrubbing by remember { mutableStateOf(false) }
val scrubDebounceJob = remember { mutableStateOf(null) }
val volumeScrollFocusDebounceJob = remember { mutableStateOf(null) }
var sliderStartPage by remember { mutableIntStateOf(0) }
var startPageThumbnail by remember { mutableStateOf(null) }
var pendingNoteForNewHighlight by remember { mutableStateOf(false) }
var highlightToNoteCfi by remember { mutableStateOf(null) }
var activeFootnoteHtml by remember { mutableStateOf(null) }
var showJustifyWarningDialog by remember { mutableStateOf(false) }
var isNavigatingByToc by remember { mutableStateOf(false) }
var chunkTargetOverride by remember { mutableStateOf(initialLocator?.let { it.blockIndex / 20 }) }
val snackbarHostState = remember { SnackbarHostState() }
var systemUiMode by remember { mutableStateOf(loadSystemUiMode(context)) }
var pageInfoMode by remember { mutableStateOf(loadPageInfoMode(context)) }
var pageInfoPosition by remember { mutableStateOf(loadPageInfoPosition(context)) }
var screenOrientationMode by remember { mutableStateOf(loadReaderScreenOrientationMode(context)) }
var rightToLeftPagination by remember { mutableStateOf(loadEpubRightToLeftPagination(context)) }
var showScreenOrientationSheet by remember { mutableStateOf(false) }
var pullToTurnEnabled by remember { mutableStateOf(loadPullToTurn(context)) }
var pullToTurnMultiplier by remember { mutableFloatStateOf(loadPullToTurnMultiplier(context)) }
var showVisualOptionsSheet by remember { mutableStateOf(false) }
ReaderScreenOrientationEffect(screenOrientationMode)
var volumeScrollEnabled by remember {
mutableStateOf(loadVolumeScrollSetting(context))
}
var tapToNavigateEnabled by remember {
mutableStateOf(loadTapToNavigateSetting(context))
}
var isPageTurnAnimationEnabled by remember {
mutableStateOf(loadPageTurnAnimationSetting(context))
}
var currentTtsMode by remember {
mutableStateOf(
loadTtsMode(context).let {
if (BuildConfig.FLAVOR == "oss" && !isByokCloudTtsAvailable(context)) TtsPlaybackManager.TtsMode.BASE else it
}
)
}
val readerCacheBookId = remember(stableBookId, epubBook.title, epubBook.fileName) {
stableBookId ?: if (epubBook.fileName.length > 20) epubBook.fileName else getBookIdForPrefs(epubBook.title)
}
val bookId = readerCacheBookId
var isPageSliderVisible by remember(bookId) {
mutableStateOf(loadReaderSliderToggled(context, bookId))
}
val locatorConverter = remember(context, readerCacheBookId) {
LocatorConverter(
bookCacheDao = BookCacheDatabase.getDatabase(context).bookCacheDao(),
proto = ProtoBuf { serializersModule = semanticBlockModule },
context = context,
stableBookId = readerCacheBookId
)
}
val userHighlights = remember(epubBook.title) {
mutableStateListOf().apply {
if (initialHighlightsJson != null) {
addAll(parseHighlightsJson(initialHighlightsJson))
} else {
addAll(loadHighlightsFromPrefs(context, epubBook.title))
}
}
}
LaunchedEffect(userHighlights.size, userHighlights.toList()) {
val json = highlightsToJson(userHighlights)
onHighlightsChanged(json)
if (initialHighlightsJson == null && userHighlights.isNotEmpty()) {
clearHighlightsFromPrefs(context, epubBook.title)
}
}
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
var isTtsCollapsed by remember { mutableStateOf(false) }
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
val initialSettings = remember(isAutoScrollLocal) {
if (isAutoScrollLocal) {
loadAutoScrollLocalSettings(context, bookId) ?: Triple(
loadAutoScrollSpeed(context),
loadAutoScrollMinSpeed(context),
loadAutoScrollMaxSpeed(context)
)
} else {
Triple(
loadAutoScrollSpeed(context),
loadAutoScrollMinSpeed(context),
loadAutoScrollMaxSpeed(context)
)
}
}
var autoScrollSpeed by remember { mutableFloatStateOf(initialSettings.first) }
var autoScrollMinSpeed by remember { mutableFloatStateOf(initialSettings.second) }
var autoScrollMaxSpeed by remember { mutableFloatStateOf(initialSettings.third) }
val onToggleAutoScrollMode = { newIsLocal: Boolean ->
isAutoScrollLocal = newIsLocal
saveAutoScrollLocalMode(context, bookId, newIsLocal)
if (newIsLocal) {
val existingLocal = loadAutoScrollLocalSettings(context, bookId)
if (existingLocal == null) {
saveAutoScrollLocalSettings(context, bookId, autoScrollSpeed, autoScrollMinSpeed, autoScrollMaxSpeed)
} else {
autoScrollSpeed = existingLocal.first
autoScrollMinSpeed = existingLocal.second
autoScrollMaxSpeed = existingLocal.third
}
} else {
autoScrollSpeed = loadAutoScrollSpeed(context)
autoScrollMinSpeed = loadAutoScrollMinSpeed(context)
autoScrollMaxSpeed = loadAutoScrollMaxSpeed(context)
}
}
val updateSpeed = { newSpeed: Float ->
autoScrollSpeed = newSpeed
if (isAutoScrollLocal) {
saveAutoScrollLocalSettings(context, bookId, newSpeed, autoScrollMinSpeed, autoScrollMaxSpeed)
} else {
saveAutoScrollSpeed(context, newSpeed)
}
}
val updateMinSpeed = { newMin: Float ->
autoScrollMinSpeed = newMin
if (isAutoScrollLocal) {
var currentMax = autoScrollMaxSpeed
var currentSpeed = autoScrollSpeed
if (currentMax < newMin) { currentMax = newMin; autoScrollMaxSpeed = newMin }
if (currentSpeed < newMin) { currentSpeed = newMin; autoScrollSpeed = newMin }
else if (currentSpeed > currentMax) { currentSpeed = currentMax; autoScrollSpeed = currentMax }
saveAutoScrollLocalSettings(context, bookId, currentSpeed, newMin, currentMax)
} else {
saveAutoScrollMinSpeed(context, newMin)
}
}
val updateMaxSpeed = { newMax: Float ->
autoScrollMaxSpeed = newMax
if (isAutoScrollLocal) {
var currentMin = autoScrollMinSpeed
var currentSpeed = autoScrollSpeed
if (currentMin > newMax) { currentMin = newMax; autoScrollMinSpeed = newMax }
if (currentSpeed > newMax) { currentSpeed = newMax; autoScrollSpeed = newMax }
else if (currentSpeed < currentMin) { currentSpeed = currentMin; autoScrollSpeed = currentMin }
saveAutoScrollLocalSettings(context, bookId, currentSpeed, currentMin, newMax)
} else {
saveAutoScrollMaxSpeed(context, newMax)
}
}
var currentHighlightPalette by remember {
mutableStateOf(loadHighlightPalette(context))
}
val onUpdateHighlightPalette: (Int, HighlightColor) -> Unit = { index, newColor ->
val newList = currentHighlightPalette.toMutableList()
if (index in newList.indices) {
newList[index] = newColor
currentHighlightPalette = newList
saveHighlightPalette(context, newList)
}
}
// Dictionary
var showAiDefinitionPopup by remember { mutableStateOf(false) }
var selectedTextForAi by remember { mutableStateOf(null) }
var aiDefinitionResult by remember { mutableStateOf(null) }
var isAiDefinitionLoading by remember { mutableStateOf(false) }
var showDictionarySettingsSheet by remember { mutableStateOf(false) }
var useOnlineDictionary by remember {
mutableStateOf(loadUseOnlineDict(context))
}
var selectedDictPackage by remember {
mutableStateOf(loadExternalDictPackage(context))
}
var selectedTranslatePackage by remember {
mutableStateOf(loadExternalTranslatePackage(context))
}
var selectedSearchPackage by remember {
mutableStateOf(loadExternalSearchPackage(context))
}
var hiddenTools by remember { mutableStateOf(loadHiddenTools(context)) }
var toolOrder by remember { mutableStateOf(loadToolOrder(context)) }
var bottomTools by remember { mutableStateOf(loadBottomTools(context)) }
var showCustomizeToolsSheet by remember { mutableStateOf(false) }
var showDictionaryUpsellDialog by remember { mutableStateOf(false) }
var showSummarizationUpsellDialog by remember { mutableStateOf(false) }
@Suppress("KotlinConstantConditions") val onDictionaryLookup = { word: String ->
val effectiveUseOnline = areReaderAiFeaturesEnabled(context) && useOnlineDictionary
if (effectiveUseOnline) {
val wordCount = countWords(word)
if (BuildConfig.FLAVOR != "oss" && wordCount > 1 && !isProUser) {
showDictionaryUpsellDialog = true
} else {
selectedTextForAi = word
showAiDefinitionPopup = true
scope.launch {
val token = viewModel.getAuthToken()
isAiDefinitionLoading = true
aiDefinitionResult = null
fetchAiDefinition(
text = word,
onUpdate = { chunk ->
val currentDefinition = aiDefinitionResult?.definition ?: ""
aiDefinitionResult = AiDefinitionResult(definition = currentDefinition + chunk)
},
authToken = token,
onError = { error ->
if (error == "INSUFFICIENT_CREDITS") {
showInsufficientCreditsDialog = true
showAiDefinitionPopup = false
isAiDefinitionLoading = false
} else {
aiDefinitionResult = AiDefinitionResult(error = error)
}
},
onFinish = { isAiDefinitionLoading = false },
context = context
)
}
}
} else {
if (!selectedDictPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchDictionary(context, selectedDictPackage!!, word)
} else {
Toast.makeText(context, context.getString(R.string.toast_select_dictionary_first), Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
}
val onTranslateLookup = { text: String ->
if (!selectedTranslatePackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchTranslate(context, selectedTranslatePackage!!, text)
} else {
Toast.makeText(context, context.getString(R.string.toast_select_translate_first), Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
val onSearchLookup = { text: String ->
if (!selectedSearchPackage.isNullOrEmpty()) {
ExternalDictionaryHelper.launchSearch(context, selectedSearchPackage!!, text)
} else {
Toast.makeText(context, context.getString(R.string.toast_select_search_first), Toast.LENGTH_SHORT).show()
showDictionarySettingsSheet = true
}
}
val summaryCacheManager = remember(context) { SummaryCacheManager(context) }
var showRecapPopup by remember { mutableStateOf(false) }
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
var epubJumpHistory by remember(readerCacheBookId) { mutableStateOf(ReaderJumpHistory()) }
var chapterToLoadOnSwitch by remember { mutableStateOf(null) }
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
var paginatedReconfigurationAnchor by remember { mutableStateOf(null) }
var isPaginatedReconfigurationRestoring by remember { mutableStateOf(false) }
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp)
var bookmarks by remember(epubBook.title) {
mutableStateOf(
loadBookmarks(context, epubBook.title, epubBook.chapters, initialBookmarksJson).also {
Timber.d("Initial load for '${epubBook.title}': ${it.size} bookmarks loaded -> $it")
}
)
}
LaunchedEffect(bookmarks) {
Timber.d("Bookmarks changed, saving...")
onBookmarksChanged(bookmarksToJson(bookmarks))
}
var activeBookmarkInVerticalView by remember { mutableStateOf(null) }
var addBookmarkRequest by remember { mutableStateOf(false) }
var isChapterReadyForBookmarkCheck by remember { mutableStateOf(false) }
var lastBookmarkCheckTime by remember { mutableLongStateOf(0L) }
var isSwitchingToPaginated by remember { mutableStateOf(false) }
val initialIsAppearanceLightStatusBars = remember(window, view) {
window?.let {
WindowCompat.getInsetsController(
it,
view
).isAppearanceLightStatusBars
} == true
}
val initialSystemBarsBehavior = remember(window, view) {
window?.let { WindowCompat.getInsetsController(it, view).systemBarsBehavior }
?: WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
val keyboardController = LocalSoftwareKeyboardController.current
var isSavingAndExiting by remember { mutableStateOf(false) }
var ttsShouldStartOnChapterLoad by remember { mutableStateOf(false) }
var userStoppedTts by remember { mutableStateOf(false) }
var ttsChapterIndex by remember { mutableStateOf(null) }
var pendingTtsLocateRequest by remember { mutableStateOf(false) }
var pendingTtsLocateReason by remember { mutableStateOf(null) }
var hasQueuedInitialTtsLocate by remember(epubBook.title) { mutableStateOf(false) }
var isDetachedFromVerticalTts by remember { mutableStateOf(false) }
var detachedVerticalTtsChunkKey by remember { mutableStateOf(null) }
var suppressNextVerticalTtsDetach by remember { mutableStateOf(false) }
var searchHighlightTarget by remember { mutableStateOf(null) }
var lastHighlightClickTime by remember { mutableLongStateOf(0L) }
var lastScrollHideTime by remember { mutableLongStateOf(0L) }
var webViewRefForTts by remember { mutableStateOf(null) }
var showAiHubSheet by remember { mutableStateOf(false) }
var summarizationResult by remember { mutableStateOf(null) }
var isSummarizationLoading by remember { mutableStateOf(false) }
var recapResult by remember { mutableStateOf(null) }
var isRecapLoading by remember { mutableStateOf(false) }
var recapProgressMessage by remember { mutableStateOf("") }
var isRequestingRecapCfi by remember { mutableStateOf(false) }
val epubSearcher = remember(epubBook) { createEpubSearcher(epubBook) }
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var showBars by remember { mutableStateOf(false) }
val chapters = remember(epubBook.chapters) { epubBook.chapters }
var readerImages by remember(epubBook) { mutableStateOf>(emptyList()) }
LaunchedEffect(epubBook) {
readerImages = withContext(Dispatchers.IO) {
epubBook.readerImageReferencesForDrawer()
}
}
var currentChapterIndex by rememberSaveable(epubBook.title) {
mutableIntStateOf(
initialLocator?.chapterIndex?.coerceIn(0, max(0, chapters.size - 1)) ?: 0
)
}
LaunchedEffect(chapters.size) {
epubJumpHistory = epubJumpHistory.pruned(chapters.size)
}
var paginator by remember { mutableStateOf(null) }
val paginatedPagerState = rememberPagerState(pageCount = {
(paginator as? BookPaginator)?.totalPageCount ?: 0
})
var isPagerInitialized by remember(initialLocator) { mutableStateOf(initialLocator == null) }
var paginatedExplicitNavigationEpoch by remember(epubBook) { mutableLongStateOf(0L) }
var paginatedExplicitNavigationAnchor by remember(epubBook) { mutableStateOf(null) }
val ttsController = viewModel.ttsController
val ttsState by ttsController.ttsState.collectAsState()
val totalBookLengthChars = remember(chapters) {
chapters.sumOf { it.plainTextContent.length.toLong() }
}
var topVisibleChunkIndex by remember { mutableIntStateOf(0) }
var loadedChunkCount by remember { mutableIntStateOf(1) }
var loadUpToChunkIndex by remember(currentChapterIndex) { mutableIntStateOf(0) }
var chapterChunks by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
var chapterChunkElementStartIndices by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
var chapterChunkElementCounts by remember(currentChapterIndex) { mutableStateOf>(emptyList()) }
var chapterHead by remember(currentChapterIndex) { mutableStateOf("") }
var isChapterParsing by remember(currentChapterIndex) { mutableStateOf(true) }
var cfiToLoad by remember { mutableStateOf(initialCfi) }
var fragmentToLoad by remember { mutableStateOf(null) }
var imageToLoad by remember { mutableStateOf(null) }
var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) }
var bookmarkPageMap by remember { mutableStateOf