Initial commit

This commit is contained in:
Aryan 2026-02-24 17:37:40 +05:30
commit 6072b2ba29
844 changed files with 220532 additions and 0 deletions

View file

@ -0,0 +1,245 @@
// AppNavigation.kt
package com.aryan.reader
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowSizeClass
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.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import com.aryan.reader.epubreader.EpubReaderScreen
import com.aryan.reader.feedback.FeedbackScreen
import com.aryan.reader.pdf.PdfViewerScreen
object AppDestinations {
const val MAIN_ROUTE = "main"
const val PDF_VIEWER_ROUTE = "pdf_viewer"
const val EPUB_READER_ROUTE = "epub_reader"
const val PRO_SCREEN_ROUTE = "pro_screen"
const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route"
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
}
@OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
fun AppNavigation(
navController: NavHostController,
windowSizeClass: WindowSizeClass,
viewModel: MainViewModel
) {
Timber.d("AppNavigation composable invoked.")
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
MainScreen(
viewModel = viewModel,
windowSizeClass = windowSizeClass,
navController = navController
)
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) {
when (uiState.selectedFileType) {
FileType.PDF -> {
if (uiState.selectedPdfUri != null) {
Timber.d("Navigating to PDF Viewer. Route: ${AppDestinations.PDF_VIEWER_ROUTE}")
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE)
}
}
}
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> {
if (uiState.selectedEpubBook != null) {
Timber.d("Navigating to EPUB Reader for ${uiState.selectedFileType}. Route: ${AppDestinations.EPUB_READER_ROUTE}")
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
navController.navigate(AppDestinations.EPUB_READER_ROUTE)
}
} else if (uiState.selectedEpubUri != null && uiState.errorMessage == null) {
Timber.d("${uiState.selectedFileType} selected, waiting for parsing/loading before navigation.")
} else if (uiState.errorMessage != null) {
Timber.w("${uiState.selectedFileType} loading failed, staying on Home. Error: ${uiState.errorMessage}")
}
}
null -> {
if (navController.currentDestination?.route != AppDestinations.MAIN_ROUTE) {
Timber.d("File cleared, ensuring navigation back to Main Screen.")
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
}
}
}
}
}
}
// PDF Viewer Screen Composable
composable(route = AppDestinations.PDF_VIEWER_ROUTE) {
Timber.d("Navigating to PDF Viewer Screen (${AppDestinations.PDF_VIEWER_ROUTE}).")
val pdfUri = uiState.selectedPdfUri
val initialPage = uiState.initialPageInBook
val initialBookmarksJson = uiState.initialBookmarksJson
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedPdfUri.toString() }?.bookId
if (pdfUri != null) {
Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage")
PdfViewerScreen(
pdfUri = pdfUri,
initialPage = initialPage,
initialBookmarksJson = initialBookmarksJson,
isProUser = uiState.isProUser,
pendingSyncUpdate = uiState.pendingSyncUpdate?.takeIf { it.bookId == bookId },
onClearPendingSyncUpdate = viewModel::clearPendingSyncUpdate,
onNavigateBack = {
Timber.d("Back action triggered from PDF Viewer.")
viewModel.clearSelectedFile()
},
onSavePosition = viewModel::savePdfReadingPosition,
onBookmarksChanged = { bookmarksJson ->
if (bookId != null) {
viewModel.saveBookmarks(bookId, bookmarksJson)
} else {
Timber.w("Could not find bookId to save PDF bookmarks for URI: ${uiState.selectedPdfUri}")
}
},
onNavigateToPro = {
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
},
viewModel = viewModel
)
} else {
Timber.w("PDF URI is null in ViewModel state while on PDF screen. Navigating back to Main.")
LaunchedEffect(Unit) {
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
}
}
}
// EPUB Reader Screen Composable
composable(route = AppDestinations.EPUB_READER_ROUTE) {
Timber.d("Navigating to EPUB Reader Screen (${AppDestinations.EPUB_READER_ROUTE}).")
val epubBook = uiState.selectedEpubBook
val isLoading = uiState.isLoading
val errorMessage = uiState.errorMessage
val initialLocator = uiState.initialLocator
val initialCfi = uiState.initialCfi
val initialBookmarksJson = uiState.initialBookmarksJson
val renderMode = uiState.renderMode
when {
epubBook != null -> {
Timber.i("Displaying EPUB Reader for Book: ${epubBook.title}, initialLocator: $initialLocator")
val coverPath = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.coverImagePath
val epubUri = uiState.selectedEpubUri
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
EpubReaderScreen(
epubBook = epubBook,
renderMode = renderMode,
initialLocator = initialLocator,
initialCfi = initialCfi,
initialBookmarksJson = initialBookmarksJson,
isProUser = uiState.isProUser,
coverImagePath = coverPath,
pendingSyncUpdate = uiState.pendingSyncUpdate?.takeIf { it.bookId == bookId },
onClearPendingSyncUpdate = viewModel::clearPendingSyncUpdate,
onNavigateBack = {
Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.")
viewModel.clearSelectedFile()
},
onSavePosition = { locator, cfiForWebView, progress ->
Timber.d("Auto-saving EPUB position: Locator $locator, Progress $progress%")
epubUri?.let { uri ->
viewModel.saveEpubReadingPosition(uri, locator, cfiForWebView, progress)
}
},
onBookmarksChanged = { bookmarksJson ->
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId
if (bookId != null) {
viewModel.saveBookmarks(bookId, bookmarksJson)
} else {
Timber.w("Could not find bookId to save bookmarks for URI: ${uiState.selectedEpubUri}")
}
},
onNavigateToPro = {
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
},
onRenderModeChange = viewModel::setRenderMode,
customFonts = customFonts,
onImportFont = viewModel::importFont,
)
}
isLoading -> {
Timber.d("EPUB Reader: Showing loading indicator.")
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
errorMessage != null -> {
Timber.e("EPUB Reader: Showing error message - $errorMessage")
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text("Error: $errorMessage", color = MaterialTheme.colorScheme.error)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
viewModel.clearSelectedFile()
}) {
Text("Go Back")
}
}
}
else -> {
Timber.w("EPUB Book is null and not loading/error state on EPUB screen. Navigating back.")
LaunchedEffect(Unit) {
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
}
}
}
}
composable(route = AppDestinations.PRO_SCREEN_ROUTE) {
ProScreen(
viewModel = viewModel,
onNavigateBack = { navController.popBackStack() }
)
}
composable(route = AppDestinations.FEEDBACK_SCREEN_ROUTE) {
FeedbackScreen(
navController = navController
)
}
composable(route = AppDestinations.FONTS_SCREEN_ROUTE) {
FontsScreen(
viewModel = viewModel,
onBackClick = { navController.popBackStack() }
)
}
}
}

View file

@ -0,0 +1,98 @@
// BookImporter.kt
package com.aryan.reader
import android.content.Context
import android.net.Uri
import timber.log.Timber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
import androidx.core.net.toUri
private const val BOOKS_DIR = "books"
class BookImporter(private val context: Context) {
private val booksDir = File(context.filesDir, BOOKS_DIR)
init {
if (!booksDir.exists()) {
booksDir.mkdirs()
}
}
/**
* Imports a book from a source URI into the app's private storage.
* @param sourceUri The content or file URI of the book to import.
* @return The [File] object for the imported book, or null if the import failed.
*/
suspend fun importBook(sourceUri: Uri): File? = withContext(Dispatchers.IO) {
var inputStream: InputStream? = null
var outputStream: FileOutputStream? = null
try {
val fileExtension = getFileExtension(sourceUri)
val destinationFileName = "${UUID.randomUUID()}.$fileExtension"
val destinationFile = File(booksDir, destinationFileName)
inputStream = context.contentResolver.openInputStream(sourceUri)
?: throw Exception("Could not open input stream for URI: $sourceUri")
outputStream = FileOutputStream(destinationFile)
inputStream.copyTo(outputStream)
Timber.i("Successfully imported book to: ${destinationFile.absolutePath}")
return@withContext destinationFile
} catch (e: Exception) {
Timber.e(e, "Failed to import book from URI: $sourceUri")
return@withContext null
} finally {
inputStream?.close()
outputStream?.close()
}
}
/**
* Deletes a book file from the app's private storage using its URI string.
* @param uriString The string representation of the file's URI.
* @return True if the file was successfully deleted, false otherwise.
*/
suspend fun deleteBookByUriString(uriString: String): Boolean = withContext(Dispatchers.IO) {
try {
val file = File(uriString.toUri().path ?: return@withContext false)
if (file.exists() && file.parentFile?.name == BOOKS_DIR) {
val deleted = file.delete()
if (deleted) {
Timber.i("Successfully deleted book file: ${file.path}")
} else {
Timber.w("Failed to delete book file: ${file.path}")
}
return@withContext deleted
}
} catch (e: Exception) {
Timber.e(e, "Error deleting book file for URI string: $uriString")
}
return@withContext false
}
fun createBookFile(fileNameWithExtension: String): File {
if (!booksDir.exists()) booksDir.mkdirs()
return File(booksDir, fileNameWithExtension)
}
private fun getFileExtension(uri: Uri): String {
val path = uri.path ?: return "tmp"
return File(path).extension.lowercase().ifEmpty {
// Fallback for URIs that don't have a clear extension in the path
when (context.contentResolver.getType(uri)) {
"application/pdf" -> "pdf"
"application/epub+zip" -> "epub"
else -> "tmp"
}
}
}
}

View file

@ -0,0 +1,966 @@
// Common.kt
package com.aryan.reader
import android.content.Context
import timber.log.Timber
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowDropUp
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
import androidx.annotation.OptIn
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Stop
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.tts.rememberTtsController
import com.aryan.reader.tts.splitTextIntoChunks
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import org.commonmark.node.AbstractVisitor
import org.commonmark.node.Code
import org.commonmark.node.Emphasis
import org.commonmark.node.HardLineBreak
import org.commonmark.node.Heading
import org.commonmark.node.ListItem
import org.commonmark.node.Paragraph
import org.commonmark.node.SoftLineBreak
import org.commonmark.node.StrongEmphasis
import org.commonmark.node.Text
import org.commonmark.parser.Parser
import java.io.File
import androidx.compose.runtime.DisposableEffect
const val aiServerBasePath = BuildConfig.AI_WORKER_URL
const val summarizeEndpoint = "/summarize"
const val summarizationUrl = aiServerBasePath + summarizeEndpoint
const val defineEndpoint = "/define"
const val aiDefinitionUrl = aiServerBasePath + defineEndpoint
const val recapEndpoint = "/recap"
const val recapUrl = aiServerBasePath + recapEndpoint
data class SearchResult(
val locationInSource: Int,
val locationTitle: String,
val snippet: AnnotatedString,
val query: String,
val occurrenceIndexInLocation: Int,
val chunkIndex: Int
)
data class AiDefinitionResult(
val definition: String? = null,
val error: String? = null
)
data class SummarizationResult(
val summary: String? = null,
val error: String? = null
)
@Stable
class SearchState(
private val scope: CoroutineScope,
private val searcher: suspend (String) -> List<SearchResult>
) {
var isSearchActive by mutableStateOf(false)
var showSearchResultsPanel by mutableStateOf(true)
var searchQuery by mutableStateOf("")
var searchResults by mutableStateOf<List<SearchResult>>(emptyList())
var isSearchInProgress by mutableStateOf(false)
var currentSearchResultIndex by mutableIntStateOf(-1)
val searchResultsCount by derivedStateOf { searchResults.size }
val hasResults by derivedStateOf { searchResults.isNotEmpty() }
private var searchJob: Job? = null
fun onQueryChange(newQuery: String) {
searchQuery = newQuery
searchJob?.cancel()
searchJob = scope.launch {
if (newQuery.isBlank()) {
searchResults = emptyList()
currentSearchResultIndex = -1
isSearchInProgress = false
return@launch
}
delay(350)
showSearchResultsPanel = true
isSearchInProgress = true
currentSearchResultIndex = -1
searchResults = searcher(newQuery)
isSearchInProgress = false
}
}
fun forceSearch() {
searchJob?.cancel()
searchJob = scope.launch {
if (searchQuery.isBlank()) {
searchResults = emptyList()
currentSearchResultIndex = -1
isSearchInProgress = false
return@launch
}
showSearchResultsPanel = true
isSearchInProgress = true
currentSearchResultIndex = -1
searchResults = searcher(searchQuery)
isSearchInProgress = false
}
}
}
@Composable
fun rememberSearchState(
scope: CoroutineScope,
searcher: suspend (String) -> List<SearchResult>
): SearchState {
return remember {
SearchState(scope, searcher)
}
}
@Composable
fun SearchTopBar(
searchState: SearchState,
focusRequester: FocusRequester,
onCloseSearch: () -> Unit
) {
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
Surface(
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onCloseSearch) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Close Search"
)
}
TextField(
value = searchState.searchQuery,
onValueChange = { searchState.onQueryChange(it) },
placeholder = { Text("Search in book...") },
modifier = Modifier
.weight(1f)
.focusRequester(focusRequester)
.testTag("SearchTextField"),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = {
searchState.forceSearch()
keyboardController?.hide()
focusManager.clearFocus()
})
)
if (searchState.searchQuery.isNotEmpty()) {
IconButton(onClick = { searchState.onQueryChange("") }) {
Icon(
Icons.Default.Close,
contentDescription = "Clear Search"
)
}
}
IconButton(onClick = {
searchState.showSearchResultsPanel = !searchState.showSearchResultsPanel
focusManager.clearFocus()
}) {
Icon(
imageVector = if (searchState.showSearchResultsPanel) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown,
contentDescription = if (searchState.showSearchResultsPanel) "Hide Results" else "Show Results"
)
}
}
}
}
@Composable
fun SearchNavigationControls(
searchState: SearchState,
onNavigate: (Int) -> Unit
) {
Surface(
shape = MaterialTheme.shapes.medium,
tonalElevation = 6.dp,
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 4.dp)
) {
IconButton(
onClick = { onNavigate(searchState.currentSearchResultIndex - 1) },
enabled = searchState.currentSearchResultIndex > 0
) {
Icon(Icons.Default.ArrowDropUp, contentDescription = "Previous Search Result")
}
Text(
text = "${searchState.currentSearchResultIndex + 1}/${searchState.searchResultsCount}",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(horizontal = 4.dp)
)
IconButton(
onClick = { onNavigate(searchState.currentSearchResultIndex + 1) },
enabled = searchState.currentSearchResultIndex < searchState.searchResultsCount - 1
) {
Icon(Icons.Default.ArrowDropDown, contentDescription = "Next Search Result")
}
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun SummarizationPopup(
title: String,
result: SummarizationResult?,
isLoading: Boolean,
onDismiss: () -> Unit,
isMainTtsActive: Boolean = false,
) {
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
LocalContext.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
DisposableEffect(Unit) {
onDispose {
if (ttsState.playbackSource == "POPUP" && (ttsState.isPlaying || ttsState.isLoading)) {
ttsController.stop()
}
}
}
Popup(
alignment = Alignment.Center,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true)
) {
Card(
modifier = Modifier
.fillMaxWidth(0.9f)
.padding(horizontal = 16.dp, vertical = 5.dp)
.heightIn(min = 150.dp, max = 500.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.padding(all = 20.dp)) {
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)
if (isLoading && (result?.summary.isNullOrBlank() && result?.error.isNullOrBlank())) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator()
Text("Generating summary...", modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
}
} else if (result != null) {
val summaryText = result.summary
val errorText = result.error
val styledContent = remember(summaryText, errorText) {
if (!summaryText.isNullOrBlank()) {
MarkdownParser.parse(summaryText)
} else {
AnnotatedString(errorText ?: "")
}
}
val textToUse = styledContent.text
if (textToUse.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
val isTtsSessionActive = ttsState.currentText != null || ttsState.isLoading
IconButton(
onClick = {
if (isTtsSessionActive) {
ttsController.stop()
} else {
val chunks = splitTextIntoChunks(textToUse).map {
TtsChunk(it, "", -1)
}
if (chunks.isNotEmpty()) {
ttsController.start(
chunks = chunks,
bookTitle = title,
chapterTitle = "Summary",
coverImageUri = null,
ttsMode = "BASE",
playbackSource = "POPUP"
)
}
}
},
enabled = !isMainTtsActive || (ttsState.playbackSource == "POPUP")
) {
Icon(
imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = if (isTtsSessionActive) "Stop" else "Read aloud"
)
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(textToUse))
}) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy"
)
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
if (errorText != null && summaryText.isNullOrBlank()) {
Text(errorText, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge)
} else if (textToUse.isNotBlank()) {
val scrollState = rememberScrollState()
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
LaunchedEffect(ttsState.currentText, textLayoutResult) {
val currentChunk = ttsState.currentText
val layoutResult = textLayoutResult
if (!currentChunk.isNullOrBlank() && layoutResult != null) {
val startIndex = textToUse.indexOf(currentChunk)
if (startIndex != -1) {
val line = layoutResult.getLineForOffset(startIndex)
val lineTop = layoutResult.getLineTop(line)
val viewportHeight = scrollState.viewportSize
val targetScroll = (lineTop - viewportHeight / 2).coerceAtLeast(0f)
scope.launch {
scrollState.animateScrollTo(targetScroll.toInt())
}
}
}
}
val annotatedText = buildAnnotatedString {
append(styledContent)
val currentChunk = ttsState.currentText
if (!currentChunk.isNullOrBlank()) {
val startIndex = textToUse.indexOf(currentChunk)
if (startIndex != -1) {
addStyle(
style = SpanStyle(background = MaterialTheme.colorScheme.primaryContainer),
start = startIndex,
end = startIndex + currentChunk.length
)
}
}
}
Text(
text = annotatedText,
modifier = Modifier.verticalScroll(scrollState),
onTextLayout = { textLayoutResult = it }
)
} else {
Text("No summary could be generated.", style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
}
@OptIn(UnstableApi::class)
@Composable
fun AiDefinitionPopup(
word: String?,
result: AiDefinitionResult?,
isLoading: Boolean,
onDismiss: () -> Unit,
isMainTtsActive: Boolean = false
) {
val ttsController = rememberTtsController()
val ttsState by ttsController.ttsState.collectAsState()
LocalContext.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
val scope = rememberCoroutineScope()
DisposableEffect(Unit) {
onDispose {
if (ttsState.playbackSource == "POPUP" && (ttsState.isPlaying || ttsState.isLoading)) {
ttsController.stop()
}
}
}
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 5.dp)
.heightIn(min = 150.dp, max = 400.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh)
) {
Column(modifier = Modifier.padding(all = 20.dp)) {
if (isLoading && (result?.definition.isNullOrBlank() && result?.error.isNullOrBlank())) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator()
Text("Thinking...", modifier = Modifier.padding(start = 12.dp), style = MaterialTheme.typography.bodyLarge)
}
} else if (result != null) {
word?.let {
Text(
text = it,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(bottom = 8.dp)
)
}
val definitionText = result.definition
val errorText = result.error
val styledContent = remember(definitionText, errorText) {
if (!definitionText.isNullOrBlank()) {
MarkdownParser.parse(definitionText)
} else {
AnnotatedString(errorText ?: "")
}
}
val textToUse = styledContent.text
if (textToUse.isNotBlank()) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
definitionText ?: errorText ?: ""
val isTtsSessionActive = ttsState.currentText != null || ttsState.isLoading
IconButton(
onClick = {
if (isTtsSessionActive) {
ttsController.stop()
} else {
val chunks = splitTextIntoChunks(textToUse).map {
TtsChunk(it, "", -1)
}
if (chunks.isNotEmpty()) {
ttsController.start(
chunks = chunks,
bookTitle = "AI Definition",
chapterTitle = word,
coverImageUri = null,
ttsMode = "BASE",
playbackSource = "POPUP"
)
}
}
},
enabled = !isMainTtsActive || (ttsState.playbackSource == "POPUP")
) {
Icon(
imageVector = if (isTtsSessionActive) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = if (isTtsSessionActive) "Stop" else "Read aloud"
)
}
Spacer(modifier = Modifier.width(8.dp))
IconButton(onClick = {
clipboardManager.setText(AnnotatedString(textToUse))
}) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "Copy"
)
}
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
if (errorText != null && definitionText.isNullOrBlank()) {
Text(errorText, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodyLarge)
} else if (textToUse.isNotBlank()) {
val scrollState = rememberScrollState()
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
LaunchedEffect(ttsState.currentText, textLayoutResult) {
val currentChunk = ttsState.currentText
val layoutResult = textLayoutResult
if (!currentChunk.isNullOrBlank() && layoutResult != null) {
val startIndex = textToUse.indexOf(currentChunk)
if (startIndex != -1) {
val line = layoutResult.getLineForOffset(startIndex)
val lineTop = layoutResult.getLineTop(line)
val viewportHeight = scrollState.viewportSize
val targetScroll = (lineTop - viewportHeight / 2).coerceAtLeast(0f)
scope.launch {
scrollState.animateScrollTo(targetScroll.toInt())
}
}
}
}
val annotatedText = buildAnnotatedString {
append(styledContent)
val currentChunk = ttsState.currentText
if (!currentChunk.isNullOrBlank()) {
val startIndex = textToUse.indexOf(currentChunk)
if (startIndex != -1) {
addStyle(
style = SpanStyle(background = MaterialTheme.colorScheme.primaryContainer),
start = startIndex,
end = startIndex + currentChunk.length
)
}
}
}
Text(
text = annotatedText,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.verticalScroll(scrollState),
onTextLayout = { textLayoutResult = it }
)
} else {
Text("AI could not provide a definition.", style = MaterialTheme.typography.bodyLarge)
}
} else if (word != null) {
Text(
text = "Asking AI about '$word'...",
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(vertical = 24.dp),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@Composable
fun SearchResultsPanel(
results: List<SearchResult>,
isSearching: Boolean,
onResultClick: (SearchResult) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
modifier = modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
when {
isSearching -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
results.isEmpty() -> {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No results found.", style = MaterialTheme.typography.bodyLarge)
}
}
else -> {
Column {
Text(
text = "${results.size} " + if (results.size == 1) "result found" else "results found",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 12.dp)
)
HorizontalDivider()
LazyColumn(modifier = Modifier.testTag("SearchResultsList")) {
items(results.size) { index ->
val result = results[index]
ListItem(
headlineContent = { Text(result.locationTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(result.snippet, style = MaterialTheme.typography.bodyMedium) },
modifier = Modifier
.clickable { onResultClick(result) }
.testTag("SearchResultItem_${result.locationInSource}")
)
HorizontalDivider()
}
}
}
}
}
}
}
suspend fun fetchAiDefinition(
text: String,
onUpdate: (String) -> Unit,
onError: (String) -> Unit,
onFinish: () -> Unit
) {
if (text.isBlank()) {
onError("Text is empty.")
onFinish()
return
}
Timber.d("Fetching AI definition for: '$text'")
withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
val url = URL(aiDefinitionUrl)
connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
connection.setRequestProperty("Accept", "application/json")
connection.connectTimeout = 10000
connection.readTimeout = 30000
connection.doOutput = true
connection.doInput = true
val jsonPayload = JSONObject().apply { put("text", text) }
connection.outputStream.use { os ->
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
}
val responseCode = connection.responseCode
Timber.d("Definition: Got response code $responseCode")
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) {
Timber.d("Definition: Received line: $line")
try {
val jsonResponse = JSONObject(line!!)
jsonResponse.optString("chunk").takeIf { it.isNotEmpty() }?.let {
Timber.d("Definition: Parsed chunk, calling onUpdate.")
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")
}
}
}
Timber.d("Definition: Finished reading stream.")
if (!hasReceivedData) {
onError("AI returned an empty definition.")
}
} else {
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
val errorDetail = try { errorBody?.let { JSONObject(it).getString("detail") } } catch (_: Exception) { "Could not get definition." }
onError("Error: $responseCode. ${errorDetail ?: "An unknown server error occurred."}")
}
} catch (e: Exception) {
Timber.e(e, "Network error fetching AI definition: ${e.message}")
onError("Network error. Check connection.")
} finally {
connection?.disconnect()
onFinish()
}
}
}
fun countWords(text: String): Int {
return text.trim().split(Regex("\\s+")).filter { it.isNotBlank() }.size
}
object MarkdownParser {
fun parse(markdown: String): AnnotatedString {
val parser = Parser.builder().build()
val document = parser.parse(markdown)
val builder = AnnotatedString.Builder()
val visitor = object : AbstractVisitor() {
override fun visit(text: Text) {
builder.append(text.literal)
}
override fun visit(emphasis: Emphasis) {
builder.pushStyle(SpanStyle(fontStyle = FontStyle.Italic))
visitChildren(emphasis)
builder.pop()
}
override fun visit(strongEmphasis: StrongEmphasis) {
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
visitChildren(strongEmphasis)
builder.pop()
}
override fun visit(paragraph: Paragraph) {
visitChildren(paragraph)
// Add newline if it's not the last node
if (paragraph.next != null) {
builder.append("\n\n")
}
}
override fun visit(heading: Heading) {
builder.pushStyle(SpanStyle(fontWeight = FontWeight.Bold))
visitChildren(heading)
builder.pop()
builder.append("\n\n")
}
override fun visit(softLineBreak: SoftLineBreak) {
builder.append(" ")
}
override fun visit(hardLineBreak: HardLineBreak) {
builder.append("\n")
}
override fun visit(code: Code) {
builder.pushStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = Color(0x22888888)))
builder.append(code.literal)
builder.pop()
}
override fun visit(listItem: ListItem) {
builder.append("")
visitChildren(listItem)
if (listItem.next != null) {
builder.append("\n")
}
}
}
document.accept(visitor)
return builder.toAnnotatedString()
}
}
class SummaryCacheManager(context: Context) {
private val cacheDir = File(context.cacheDir, "chapter_summaries")
init {
if (!cacheDir.exists()) {
cacheDir.mkdirs()
}
}
private fun getFileName(bookTitle: String, chapterIndex: Int): String {
// Sanitize title to be file-system safe
val safeTitle = bookTitle.replace(Regex("[^a-zA-Z0-9.-]"), "_")
return "summary_${safeTitle}_$chapterIndex.txt"
}
fun saveSummary(bookTitle: String, chapterIndex: Int, summary: String) {
try {
val file = File(cacheDir, getFileName(bookTitle, chapterIndex))
file.writeText(summary)
Timber.d("Saved summary for $bookTitle Ch $chapterIndex")
} catch (e: Exception) {
Timber.e(e, "Failed to save summary")
}
}
fun getSummary(bookTitle: String, chapterIndex: Int): String? {
return try {
val file = File(cacheDir, getFileName(bookTitle, chapterIndex))
if (file.exists()) file.readText() else null
} catch (_: Exception) {
null
}
}
fun hasSummary(bookTitle: String, chapterIndex: Int): Boolean {
val file = File(cacheDir, getFileName(bookTitle, chapterIndex))
return file.exists()
}
}
suspend fun fetchRecap(
pastSummaries: List<String>,
currentText: String,
onUpdate: (String) -> Unit,
onError: (String) -> Unit,
onFinish: () -> Unit
) {
if (pastSummaries.isEmpty() && currentText.isBlank()) {
onError("Not enough context for a recap.")
onFinish()
return
}
withContext(Dispatchers.IO) {
var connection: HttpURLConnection? = null
try {
val url = URL(recapUrl)
connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
connection.setRequestProperty("Accept", "application/json")
connection.connectTimeout = 15000
connection.readTimeout = 120000
connection.doOutput = true
connection.doInput = true
val jsonPayload = JSONObject().apply {
put("past_summaries", org.json.JSONArray(pastSummaries))
put("current_text", currentText)
}
connection.outputStream.use { os ->
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
}
val responseCode = connection.responseCode
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!!)
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 recap.")
} else {
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { null }
onError("Error: $responseCode. ${errorBody ?: ""}")
}
} catch (e: Exception) {
Timber.e(e, "Recap error: ${e.message}")
onError("Network error during recap generation.")
} finally {
connection?.disconnect()
onFinish()
}
}
}

View file

@ -0,0 +1,39 @@
package com.aryan.reader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.security.MessageDigest
object FileHasher {
/**
* Calculates the SHA-256 hash of an input stream.
* @param inputStreamProvider A lambda that provides the InputStream. This is important to ensure
* the stream is opened on the correct thread.
* @return The SHA-256 hash as a hex string, or null if an error occurs.
*/
suspend fun calculateSha256(inputStreamProvider: () -> InputStream?): String? = withContext(Dispatchers.IO) {
try {
val digest = MessageDigest.getInstance("SHA-256")
inputStreamProvider()?.use { inputStream ->
val buffer = ByteArray(8192)
var bytesRead: Int
while (inputStream.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
} ?: return@withContext null // Return null if stream provider returns null
// Convert byte array to hex string
val hashBytes = digest.digest()
val hexString = StringBuilder()
for (byte in hashBytes) {
hexString.append(String.format("%02x", byte))
}
hexString.toString()
} catch (e: Exception) {
// In a real app, you'd want to log this error
e.printStackTrace()
null
}
}
}

View file

@ -0,0 +1,204 @@
package com.aryan.reader
import android.content.Context
import android.net.Uri
import timber.log.Timber
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.core.content.edit
class FolderSyncWorker(
private val appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext)
private val bookImporter = BookImporter(appContext)
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
companion object {
const val WORK_NAME = "FolderSyncWorker"
}
override suspend fun doWork(): Result {
Timber.d("Worker starting folder sync check.")
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null)
if (folderUriString.isNullOrBlank()) {
Timber.d("No sync folder configured. Worker stopping.")
return Result.success()
}
val folderUri = folderUriString.toUri()
return withContext(Dispatchers.IO) {
try {
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
if (documentTree == null || !documentTree.isDirectory) {
Timber.e("Could not read the synced folder URI: $folderUriString. Cancelling worker.")
WorkManager.getInstance(appContext).cancelUniqueWork(WORK_NAME)
return@withContext Result.failure()
}
val filesToScan = mutableListOf<DocumentFile>()
val fileQueue = ArrayDeque<DocumentFile>()
documentTree.listFiles().let { fileQueue.addAll(it) }
while (fileQueue.isNotEmpty()) {
val file = fileQueue.removeAt(0)
if (file.isDirectory) {
file.listFiles().let { fileQueue.addAll(it) }
} else if (file.isFile) {
val fileName = file.name ?: ""
if (fileName.endsWith(".pdf", true) || fileName.endsWith(".epub", true) || fileName.endsWith(".mobi", true) || fileName.endsWith(".azw3", true)) {
filesToScan.add(file)
}
}
}
var importedCount = 0
for (file in filesToScan) {
val importResult = prepareBookForImport(file.uri)
if (importResult != null) {
val (internalUri, bookId, type) = importResult
val displayName = file.name ?: "Unknown File"
addBookToDatabase(internalUri, type, bookId, displayName, folderUriString)
importedCount++
}
}
if (importedCount > 0) {
Timber.d("Worker successfully imported $importedCount new book(s).")
} else {
Timber.d("Worker found no new books to import.")
}
prefs.edit {
putLong(
MainViewModel.KEY_LAST_FOLDER_SCAN_TIME,
System.currentTimeMillis()
)
}
Result.success()
} catch (e: Exception) {
Timber.e(e, "Error during folder sync worker execution.")
Result.failure()
}
}
}
private suspend fun prepareBookForImport(externalUri: Uri): Triple<Uri, String, FileType>? {
val type = getFileTypeFromUri(externalUri, appContext) ?: return null
val hash = FileHasher.calculateSha256 {
appContext.contentResolver.openInputStream(externalUri)
} ?: return null
if (recentFilesRepository.getFileByBookId(hash) != null) {
return null // Already exists
}
val internalFile = bookImporter.importBook(externalUri) ?: return null
return Triple(internalFile.toUri(), hash, type)
}
private fun getFileNameFromUri(uri: Uri): String? {
return DocumentFile.fromSingleUri(appContext, uri)?.name
}
private suspend fun addBookToDatabase(
uri: Uri,
type: FileType,
bookId: String,
displayName: String,
sourceFolderUri: String
) {
var coverPath: String? = null
var title: String? = null
var author: String? = null
if (type == FileType.EPUB || type == FileType.MOBI) {
val book = withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
if (type == FileType.EPUB) {
epubParser.createEpubBook(
inputStream = inputStream,
originalBookNameHint = displayName
)
} else {
mobiParser.createMobiBook(
inputStream = inputStream,
originalBookNameHint = displayName
)
}
}
}
if (book != null) {
title = book.title.takeIf { it.isNotBlank() } ?: displayName
author = book.author.takeIf { it.isNotBlank() }
book.coverImage?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
}
} else if (type == FileType.PDF) {
title = displayName
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
}
val newItem = RecentFileItem(
bookId = bookId,
uriString = uri.toString(),
type = type,
displayName = displayName,
timestamp = System.currentTimeMillis(),
coverImagePath = coverPath,
title = title,
author = author,
isAvailable = true,
lastModifiedTimestamp = System.currentTimeMillis(),
isDeleted = false,
isRecent = false, // Books from folder sync should not appear on the Home screen
sourceFolderUri = sourceFolderUri
)
recentFilesRepository.addRecentFile(newItem)
Timber.i("Worker added new book to database: $displayName")
}
private fun getFileTypeFromUri(uri: Uri, context: Context): FileType? {
val mimeType = context.contentResolver.getType(uri)
return when (mimeType) {
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/x-mobipocket-ebook",
"application/vnd.amazon.ebook",
"application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
else -> {
val path = getFileNameFromUri(uri)
when {
path?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF
path?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
path?.endsWith(".mobi", ignoreCase = true) == true -> FileType.MOBI
path?.endsWith(".azw3", ignoreCase = true) == true -> FileType.MOBI
path?.endsWith(".prc", ignoreCase = true) == true -> FileType.MOBI
else -> null
}
}
}
}
}

View file

@ -0,0 +1,254 @@
// FontsScreen.kt
package com.aryan.reader
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aryan.reader.data.CustomFontEntity
import java.io.File
@Composable
fun FontsScreen(
viewModel: MainViewModel,
onBackClick: () -> Unit
) {
val fonts: List<CustomFontEntity> by viewModel.customFonts.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// Dialog state
var showDeleteDialog by remember { mutableStateOf(false) }
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
val pickFontLauncher = rememberFilePickerLauncher { uris ->
uris.firstOrNull()?.let { viewModel.importFont(it) }
}
// Font mime types filter
val fontMimeTypes = arrayOf(
"font/ttf",
"font/otf",
"font/woff2",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-woff2",
"application/vnd.ms-opentype",
"application/x-font-opentype"
)
Scaffold(
modifier = Modifier.statusBarsPadding(), // Fixes content flowing under status bar
topBar = {
CustomTopAppBar(
title = { Text("Custom Fonts") },
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
)
},
floatingActionButton = {
// Hide FAB when empty state is visible (list is empty)
if (fonts.isNotEmpty()) {
ExtendedFloatingActionButton(
onClick = { pickFontLauncher.launch(fontMimeTypes) },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text("Import Font") }
)
}
}
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
if (fonts.isEmpty()) {
EmptyState(
title = "No Custom Fonts",
message = "Import TTF or OTF files to use them in your books.",
onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
modifier = Modifier.fillMaxSize()
)
} else {
LazyColumn(
modifier = Modifier.fillMaxSize(),
// Padding bottom 88.dp allows scrolling past the FloatingActionButton
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(fonts, key = { it.id }) { font ->
FontListItem(
font = font,
onDelete = {
fontToDelete = font
showDeleteDialog = true
}
)
}
}
}
if (uiState.isLoading) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f)
) {
Box(contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
}
// Banner messages removed as requested
}
}
if (showDeleteDialog && fontToDelete != null) {
DeleteFontConfirmationDialog(
fontName = fontToDelete!!.displayName,
onConfirm = {
fontToDelete?.let { viewModel.deleteFont(it.id) }
showDeleteDialog = false
fontToDelete = null
},
onDismiss = {
showDeleteDialog = false
fontToDelete = null
}
)
}
}
@Composable
fun FontListItem(
font: CustomFontEntity,
onDelete: () -> Unit
) {
val customTypeface = remember(font.path) {
try {
FontFamily(Font(File(font.path)))
} catch (_: Exception) {
null
}
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = font.displayName,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
Icon(
Icons.Default.Delete,
contentDescription = "Delete",
tint = MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp))
Box(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), MaterialTheme.shapes.small)
.padding(12.dp)
) {
if (customTypeface != null) {
Text(
text = "Grumpy wizards make toxic brew for the evil queen! 1234567890 ?.,;:",
fontFamily = customTypeface,
fontSize = 18.sp,
color = MaterialTheme.colorScheme.onSurface
)
} else {
Text(
text = "Preview unavailable (Invalid font file)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = font.fileExtension.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
fun DeleteFontConfirmationDialog(
fontName: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Delete Font?") },
text = { Text("Are you sure you want to delete '$fontName'? This will remove it from all your devices if sync is on.") },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,91 @@
// src/main/java/com/aryan/reader/MainActivity.kt
package com.aryan.reader
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.webkit.WebView
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.RequiresApi
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController
import com.aryan.reader.data.PlatformFeaturesRepository // Import the new repo
import com.aryan.reader.ui.theme.AppTheme
import kotlinx.coroutines.launch
import timber.log.Timber
class MainActivity : ComponentActivity() {
private val viewModel: MainViewModel by viewModels()
private lateinit var platformFeaturesRepository: PlatformFeaturesRepository
private val updateLauncher = registerForActivityResult(
ActivityResultContracts.StartIntentSenderForResult()
) { result ->
if (result.resultCode != RESULT_OK) {
Timber.e("Update flow failed! Result code: ${result.resultCode}")
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
platformFeaturesRepository = PlatformFeaturesRepository(this)
lifecycleScope.launch {
viewModel.reviewRequestEvent.collect {
platformFeaturesRepository.requestReview(this@MainActivity)
}
}
handleIntent(intent)
lifecycleScope.launch {
platformFeaturesRepository.checkForUpdates(this@MainActivity, updateLauncher)
}
setContent {
AppTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
val windowSizeClass = calculateWindowSizeClass(this)
val navController = rememberNavController()
AppNavigation(
navController = navController,
windowSizeClass = windowSizeClass,
viewModel = viewModel
)
}
}
}
WebView.setWebContentsDebuggingEnabled(true)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent?) {
if (intent?.action == Intent.ACTION_VIEW && intent.data != null) {
Timber.d("Received VIEW intent with URI: ${intent.data}")
val uri = intent.data!!
viewModel.onFileSelected(uri)
}
}
}

View file

@ -0,0 +1,94 @@
// MainScreen.kt
package com.aryan.reader
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import kotlinx.coroutines.launch
import androidx.compose.runtime.getValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
sealed class BottomBarScreen(val route: String, val label: String, val iconResId: Int) {
object Home : BottomBarScreen("home", "Home", R.drawable.home)
object Library : BottomBarScreen("library", "Library", R.drawable.library_books)
}
private val bottomBarItems = listOf(
BottomBarScreen.Home,
BottomBarScreen.Library,
)
@Composable
fun MainScreen(
viewModel: MainViewModel,
windowSizeClass: WindowSizeClass,
navController: NavHostController
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val viewingShelfName = uiState.viewingShelfName
if (viewingShelfName != null) {
ShelfScreen(viewModel = viewModel)
} else {
val pagerState = rememberPagerState(
initialPage = uiState.mainScreenStartPage,
pageCount = { bottomBarItems.size }
)
val scope = rememberCoroutineScope()
LaunchedEffect(pagerState.currentPage) {
viewModel.setMainScreenPage(pagerState.currentPage)
}
Scaffold(
bottomBar = {
NavigationBar {
bottomBarItems.forEachIndexed { index, screen ->
NavigationBarItem(
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = screen.label) },
label = { Text(screen.label) },
selected = pagerState.currentPage == index,
onClick = {
scope.launch {
pagerState.animateScrollToPage(index)
}
}
)
}
}
}
) { innerPadding ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
key = { bottomBarItems[it].route },
beyondViewportPageCount = 1,
userScrollEnabled = false
) { page ->
when (page) {
0 -> HomeScreen(
viewModel = viewModel,
windowSizeClass = windowSizeClass,
navController = navController
)
1 -> LibraryScreen(viewModel = viewModel)
}
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
// MyApplication.kt
package com.aryan.reader
import android.app.Application
import android.webkit.WebView
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.SvgDecoder
import com.aryan.reader.paginatedreader.SvgStringFetcher
import timber.log.Timber // Add this
class MyApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
Timber.plant(Timber.DebugTree())
WebView.setWebContentsDebuggingEnabled(true)
}
}
override fun newImageLoader(): ImageLoader {
Timber.d("MyApplication: Creating custom ImageLoader with SvgStringFetcher.")
return ImageLoader.Builder(this)
.components {
add(SvgStringFetcher.Factory())
add(SvgDecoder.Factory())
}
.build()
}
}

View file

@ -0,0 +1,711 @@
// ProScreen.kt
package com.aryan.reader
import android.app.Activity
import timber.log.Timber
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
import java.text.NumberFormat
import java.util.Currency
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun ProScreen(
viewModel: MainViewModel,
onNavigateBack: () -> Unit
) {
val context = LocalContext.current
val proUpgradeState by viewModel.proUpgradeState.collectAsState()
val uiState by viewModel.uiState.collectAsState()
var showExistingPurchaseDialog by remember { mutableStateOf(false) }
var showEarlyAccessInfoDialog by remember { mutableStateOf(false) }
var showSignInRequiredDialog by remember { mutableStateOf(false) }
val pagerState = rememberPagerState(initialPage = 1, pageCount = { 2 })
var selectedTabIndex by remember { mutableIntStateOf(1) }
val scope = rememberCoroutineScope()
LaunchedEffect(pagerState.currentPage) {
selectedTabIndex = pagerState.currentPage
}
LaunchedEffect(selectedTabIndex) {
scope.launch {
pagerState.animateScrollToPage(selectedTabIndex)
}
}
LaunchedEffect(uiState.isProUser) {
if (uiState.isProUser) {
selectedTabIndex = 1
}
}
LaunchedEffect(proUpgradeState.error) {
proUpgradeState.error?.let {
Toast.makeText(context, it, Toast.LENGTH_LONG).show()
viewModel.clearBillingError()
}
}
if (showExistingPurchaseDialog) {
ExistingPurchaseDialog(onDismiss = { showExistingPurchaseDialog = false })
}
if (showEarlyAccessInfoDialog) {
EarlyAccessInfoDialog(onDismiss = { showEarlyAccessInfoDialog = false })
}
if (showSignInRequiredDialog) {
SignInRequiredDialog(
onSignInClick = {
scope.launch {
context.findActivity()?.let { activity ->
viewModel.signIn(activity)
}
}
showSignInRequiredDialog = false
},
onDismiss = { showSignInRequiredDialog = false }
)
}
Scaffold(
topBar = {
TopAppBar(
title = { }, // Removed header content
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = paddingValues.calculateTopPadding(), start = 16.dp, end = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top
) {
Spacer(modifier = Modifier.height(16.dp))
// Tabs
TabRow(
selectedTabIndex = selectedTabIndex,
modifier = Modifier
.fillMaxWidth()
.clip(MaterialTheme.shapes.extraLarge)
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)),
indicator = {},
divider = {}
) {
Tab(
selected = selectedTabIndex == 0,
onClick = { selectedTabIndex = 0 },
modifier = Modifier
.height(56.dp)
.clip(CircleShape)
.background(
if (selectedTabIndex == 0) MaterialTheme.colorScheme.surface else Color.Transparent
)
.border( // Border for selected Free tab
width = if (selectedTabIndex == 0) 2.dp else 0.dp,
color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = CircleShape
),
text = {
AutoSizeText(
"Free",
style = LocalTextStyle.current.copy(
color = if (selectedTabIndex == 0) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
)
},
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
Tab(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
modifier = Modifier
.height(56.dp)
.clip(CircleShape)
.background(
if (selectedTabIndex == 1) MaterialTheme.colorScheme.surface else Color.Transparent
)
.border( // Border for selected Pro tab
width = if (selectedTabIndex == 1) 2.dp else 0.dp,
color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else Color.Transparent,
shape = CircleShape
),
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.crown),
contentDescription = "Pro",
modifier = Modifier.size(16.dp),
tint = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(4.dp))
AutoSizeText(
"Episteme Pro",
style = LocalTextStyle.current.copy(
color = if (selectedTabIndex == 1) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
)
}
},
selectedContentColor = MaterialTheme.colorScheme.primary,
unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.height(16.dp))
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
userScrollEnabled = true
) { page ->
if (page == 0) {
FreeTierCard()
} else {
ProTierCard(
isProUser = uiState.isProUser,
isUserSignedIn = uiState.currentUser != null,
proUpgradeState = proUpgradeState,
onUpgradeClick = {
(context as? Activity)?.let {
viewModel.launchPurchaseFlow(it)
}
},
onShowExistingPurchaseDialog = { showExistingPurchaseDialog = true },
onShowEarlyAccessInfo = { showEarlyAccessInfoDialog = true },
onSignInRequiredClick = { showSignInRequiredDialog = true }
)
}
}
}
}
}
@Composable
private fun FreeTierCard() {
Card(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Free Plan",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "$0",
style = MaterialTheme.typography.displaySmall.copy(fontSize = 48.sp),
fontWeight = FontWeight.Bold
)
Text(
text = "Forever free",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
FeatureListItem(iconRes = R.drawable.library_books, text = "Multiple Formats")
Text(
text = "Supports PDF, EPUB, MOBI, AZW3",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.text_to_speech, text = "Android Text-to-Speech")
Text(
text = "Listen to your books with built-in TTS",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.dictionary, text = "Basic Dictionary")
Text(
text = "Look up single words quickly",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { /* Do nothing, it's the current plan */ },
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = MaterialTheme.shapes.medium,
enabled = false,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
)
) {
Text("Current Plan", fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
}
}
}
}
@Composable
private fun ProTierCard(
isProUser: Boolean,
isUserSignedIn: Boolean,
proUpgradeState: ProUpgradeState,
onUpgradeClick: () -> Unit,
onShowExistingPurchaseDialog: () -> Unit,
onShowEarlyAccessInfo: () -> Unit,
onSignInRequiredClick: () -> Unit
) {
val productDetails = proUpgradeState.productDetails
val billingClientReady = proUpgradeState.billingClientReady
val localPurchaseExistsForOtherAccount = !isProUser && proUpgradeState.hasValidPurchase
var originalFormattedPrice by remember { mutableStateOf("$9.99") }
LaunchedEffect(productDetails) {
productDetails?.let { details ->
val priceAmountMicros = details.priceAmountMicros
val priceCurrencyCode = details.currencyCode
val originalPriceMicros = priceAmountMicros * 2
val currencyFormatter = NumberFormat.getCurrencyInstance().apply {
try {
currency = Currency.getInstance(priceCurrencyCode)
} catch (e: IllegalArgumentException) {
Timber.e(e, "Invalid currency code: $priceCurrencyCode")
}
}
originalFormattedPrice = currencyFormatter.format(originalPriceMicros / 1_000_000.0)
}
}
Card(
modifier = Modifier.fillMaxWidth().fillMaxHeight(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.crown),
contentDescription = "Pro Badge",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Episteme Pro",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(8.dp))
if (!isProUser) {
val formattedPrice = productDetails?.formattedPrice
if (formattedPrice != null) {
Text(
text = buildAnnotatedString {
withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) {
append(originalFormattedPrice)
}
append(" 50% OFF")
},
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = formattedPrice,
style = MaterialTheme.typography.displaySmall.copy(fontSize = 48.sp),
fontWeight = FontWeight.Bold
)
} else {
Text(
text = "Loading price...",
style = MaterialTheme.typography.displaySmall.copy(fontSize = 32.sp),
fontWeight = FontWeight.Bold
)
}
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "One-time payment",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(8.dp))
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) {
Text(
text = "Lifetime Access",
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
OutlinedButton(
onClick = onShowEarlyAccessInfo,
modifier = Modifier
.height(40.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary),
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.primary
),
shape = MaterialTheme.shapes.small,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Info",
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("Early Access Sale", style = MaterialTheme.typography.labelLarge)
}
Spacer(modifier = Modifier.height(16.dp))
}
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.Start
) {
Text(
text = "Everything in Free, plus:",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.cloud_sync, text = "Cloud Sync Across Devices")
Text(
text = "Keep your entire library, including book files and reading progress, synced across up to 4 devices.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.summarize, text = "Summarization")
Text(
text = "Get quick summaries of chapters or pages",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.dictionary, text = "Smart Dictionary")
Text(
text = "Search phrases and even paragraphs, not just single words",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
FeatureListItem(iconRes = R.drawable.chat_bubble, text = "Priority Feature Requests")
Text(
text = "Your suggestions get prioritized",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 36.dp, bottom = 8.dp)
)
}
Spacer(modifier = Modifier.height(16.dp))
if (isProUser) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
.background(
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f),
MaterialTheme.shapes.medium
)
) {
Icon(
painter = painterResource(id = R.drawable.crown),
contentDescription = "Unlocked",
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Pro Features Unlocked!",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary
)
}
} else {
when {
!isUserSignedIn -> {
Button(
onClick = onSignInRequiredClick,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = MaterialTheme.shapes.medium,
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f),
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
AutoSizeText("Sign in Required", style = LocalTextStyle.current.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold))
}
}
proUpgradeState.isVerifying -> {
Button(
onClick = {},
enabled = false,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = MaterialTheme.shapes.medium,
) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = LocalContentColor.current
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text("Verifying purchase...")
}
}
localPurchaseExistsForOtherAccount -> {
OutlinedButton(
onClick = onShowExistingPurchaseDialog,
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = MaterialTheme.shapes.medium
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "Info",
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
AutoSizeText("Existing Purchase Found")
}
}
productDetails != null -> {
Button(
onClick = {
Timber.d("Upgrade button clicked.")
onUpgradeClick()
},
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
shape = MaterialTheme.shapes.medium,
enabled = billingClientReady
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = painterResource(id = R.drawable.crown),
contentDescription = "Pro",
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
AutoSizeText("Get Lifetime Access", style = LocalTextStyle.current.copy(fontSize = 16.sp, fontWeight = FontWeight.SemiBold))
}
}
}
!billingClientReady -> {
Box(modifier = Modifier.height(48.dp), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
}
else -> {
Text(
text = "Upgrade currently unavailable. Please check your internet and try again.",
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.height(48.dp)
)
}
}
// Text area below button
Spacer(modifier = Modifier.height(16.dp)) // Increased spacing
when {
!isUserSignedIn -> {
Text(
text = "Please sign in to your Google account to purchase Episteme Pro.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
proUpgradeState.isVerifying -> {
Text(
text = "This may take a few moments. Your Pro status will be updated automatically.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center
)
}
else -> {
LegalText(prefixText = "By purchasing,")
}
}
}
}
}
}
@Composable
private fun FeatureListItem(@androidx.annotation.DrawableRes iconRes: Int? = null, icon: ImageVector? = null, text: String) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (iconRes != null) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
} else if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
} else {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(16.dp))
Text(text = text, style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
fun ExistingPurchaseDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text("Existing Purchase Found") },
text = { Text("This device already has a Pro purchase, but it's linked to a different account. Please sign in to the account that was used for the original purchase to restore your Pro features.") },
confirmButton = {
TextButton(onClick = onDismiss) { Text("OK") }
}
)
}
@Composable
fun EarlyAccessInfoDialog(onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text("Early Access Sale") },
text = { Text("You're getting Episteme Pro at a special discounted price during our early access period! This is a limited-time offer.") },
confirmButton = {
TextButton(onClick = onDismiss) { Text("Got It!") }
}
)
}
@Composable
fun SignInRequiredDialog(onSignInClick: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
icon = { Icon(painter = painterResource(id = R.drawable.crown), contentDescription = null) }, // Using crown icon for Pro
title = { Text("Sign In Required") },
text = { Text("Please sign in to your Google account to purchase Episteme Pro and unlock all premium features.") },
confirmButton = {
TextButton(onClick = onSignInClick) { Text("Sign In") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Not Now") }
}
)
}

View file

@ -0,0 +1,510 @@
// SharedComposables.kt
package com.aryan.reader
import android.net.Uri
import timber.log.Timber
import androidx.activity.compose.ManagedActivityResultLauncher
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.material3.LocalTextStyle
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.text.TextStyle
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
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.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.outlined.FileOpen
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.aryan.reader.data.RecentFileItem
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import androidx.compose.foundation.text.ClickableText
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.sp
import android.content.Context
import android.content.Intent
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.UriHandler
import androidx.core.net.toUri
internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html"
class CustomTabUriHandler(private val context: Context) : UriHandler {
override fun openUri(uri: String) {
val customTabsIntent = CustomTabsIntent.Builder()
.setShowTitle(true)
.build()
try {
customTabsIntent.launchUrl(context, uri.toUri())
} catch (e: Exception) {
Timber.e(e, "Failed to launch Custom Tab, falling back to browser.")
val browserIntent = Intent(Intent.ACTION_VIEW, uri.toUri()).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(browserIntent)
}
}
}
@Composable
fun LegalText(
modifier: Modifier = Modifier,
prefixText: String, // Changed from baseText
textAlign: TextAlign = TextAlign.Center
) {
val uriHandler = LocalUriHandler.current
val annotatedString = buildAnnotatedString {
append("$prefixText you agree to our ")
pushStringAnnotation(tag = "terms", annotation = TERMS_URL)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
)
) {
append("Terms of Service")
}
pop()
append(" and acknowledge you have read our ")
pushStringAnnotation(tag = "privacy", annotation = PRIVACY_POLICY_URL)
withStyle(
style = SpanStyle(
color = MaterialTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline
)
) {
append("Privacy Policy")
}
pop()
append(".")
}
@Suppress("DEPRECATION")
ClickableText(
text = annotatedString,
style = MaterialTheme.typography.bodySmall.copy(
textAlign = textAlign,
color = MaterialTheme.colorScheme.onSurfaceVariant,
lineHeight = 18.sp
),
modifier = modifier,
onClick = { offset ->
annotatedString.getStringAnnotations(tag = "terms", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
uriHandler.openUri(annotation.item)
}
annotatedString.getStringAnnotations(tag = "privacy", start = offset, end = offset)
.firstOrNull()?.let { annotation ->
uriHandler.openUri(annotation.item)
}
}
)
}
@Composable
fun rememberFilePickerLauncher(
onFilesSelected: (List<Uri>) -> Unit
): ManagedActivityResultLauncher<Array<String>, List<@JvmSuppressWildcards Uri>> {
return rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenMultipleDocuments(),
onResult = { uris: List<Uri> ->
if (uris.isNotEmpty()) {
Timber.d("${uris.size} file(s) selected.")
onFilesSelected(uris)
} else {
Timber.d("File selection cancelled.")
}
}
)
}
@Composable
fun ContextualTopAppBar(
selectedItemCount: Int,
onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null,
onDeleteClick: () -> Unit
) {
CustomTopAppBar(
title = { Text("$selectedItemCount selected") },
navigationIcon = {
IconButton(onClick = onNavIconClick) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Clear Selection")
}
},
actions = {
if (selectedItemCount == 1 && onInfoClick != null) {
IconButton(onClick = onInfoClick) {
Icon(Icons.Filled.Info, contentDescription = "Info")
}
}
if (onSelectAllClick != null) {
IconButton(onClick = onSelectAllClick) {
Icon(Icons.Filled.SelectAll, contentDescription = "Select All")
}
}
IconButton(onClick = onDeleteClick) {
Icon(Icons.Filled.Delete, contentDescription = "Delete")
}
}
)
}
@Composable
fun CustomTopAppBar(
modifier: Modifier = Modifier,
title: @Composable () -> Unit,
navigationIcon: @Composable () -> Unit = {},
actions: @Composable RowScope.() -> Unit = {}
) {
Surface(
modifier = modifier
.fillMaxWidth()
.height(56.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
shadowElevation = 2.dp
) {
Row(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
navigationIcon()
Box(
modifier = Modifier
.weight(1f)
.padding(start = 12.dp),
contentAlignment = Alignment.CenterStart
) {
ProvideTextStyle(value = MaterialTheme.typography.titleLarge) {
title()
}
}
Row(
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
actions()
}
}
}
}
@Composable
fun DeleteConfirmationDialog(count: Int, onConfirm: () -> Unit, onDismiss: () -> Unit, isPermanentDelete: Boolean = false) {
val title = if (isPermanentDelete) "Delete File(s) Permanently" else "Remove from Recents"
val text = if (isPermanentDelete) {
"Do you want to permanently delete $count selected file(s) from your device? This action cannot be undone."
} else {
"Do you want to remove $count selected file(s) from the recent files list? It will reappear if you open it again from the library."
}
val confirmText = if (isPermanentDelete) "Delete" else "Remove"
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(text) },
confirmButton = {
TextButton(onClick = onConfirm) { Text(confirmText) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit) {
val formattedDate = remember(item.timestamp) {
SimpleDateFormat("MMM dd, yyyy HH:mm", Locale.getDefault()).format(Date(item.timestamp))
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("File Information") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
val displayName = item.displayName
val epubTitle = item.title
if (item.type != FileType.PDF && !epubTitle.isNullOrBlank()) {
InfoRow("Title:", epubTitle, maxLines = 3)
if (displayName != epubTitle) {
InfoRow("File Name:", displayName, maxLines = 2)
}
} else {
InfoRow("File Name:", displayName, maxLines = 2)
}
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
InfoRow("Author:", it, maxLines = 2)
}
InfoRow("File Type:", item.type.name)
InfoRow("Date Added:", formattedDate)
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("OK") }
}
)
}
@Composable
private fun InfoRow(label: String, value: String?, maxLines: Int = 1) {
if (value.isNullOrBlank()) return
Row {
Text(
text = label,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.width(90.dp)
.padding(end = 8.dp)
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium,
maxLines = maxLines,
overflow = TextOverflow.Ellipsis
)
}
}
@Composable
fun CustomTopBanner(bannerMessage: BannerMessage?) {
AnimatedVisibility(
visible = bannerMessage != null,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding(),
contentAlignment = Alignment.TopCenter
) {
Surface(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
color = if (bannerMessage?.isError == true) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer,
shape = MaterialTheme.shapes.medium,
shadowElevation = 8.dp
) {
Text(
text = bannerMessage?.message ?: "",
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
color = if (bannerMessage?.isError == true) MaterialTheme.colorScheme.onErrorContainer else MaterialTheme.colorScheme.onSecondaryContainer,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
}
}
}
}
@Suppress("KotlinConstantConditions")
@Composable
fun AboutDialog(onDismiss: () -> Unit) {
val uriHandler = LocalUriHandler.current
val isOss = BuildConfig.FLAVOR == "oss"
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("About Episteme") },
text = {
Column {
Text(
"Version: ${BuildConfig.VERSION_NAME} (Build: ${BuildConfig.VERSION_CODE})",
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(bottom = 12.dp)
)
// Only show legal links in the Pro version
if (!isOss) {
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Privacy Policy",
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier
.fillMaxWidth()
.clickable { uriHandler.openUri(PRIVACY_POLICY_URL) }
.padding(vertical = 8.dp)
)
Text(
text = "Terms of Service",
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier
.fillMaxWidth()
.clickable { uriHandler.openUri(TERMS_URL) }
.padding(vertical = 8.dp)
)
Text(
text = "Licenses",
style = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.primary),
modifier = Modifier
.fillMaxWidth()
.clickable { uriHandler.openUri(LICENSES_URL) }
.padding(vertical = 8.dp)
)
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close") }
}
)
}
@Composable
fun EmptyState(
title: String,
message: String,
onSelectFileClick: () -> Unit,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxSize()
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Outlined.FileOpen,
contentDescription = "No files icon",
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(24.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
SelectFileButton(onClick = onSelectFileClick, text = "Select a File")
}
}
@Composable
fun SelectFileButton(onClick: () -> Unit, text: String) {
FilledTonalButton(
onClick = onClick,
shape = MaterialTheme.shapes.medium
) {
Icon(Icons.Default.Add, contentDescription = null, modifier = Modifier.size(ButtonDefaults.IconSize))
Spacer(Modifier.size(ButtonDefaults.IconSpacing))
Text(text)
}
}
@Composable
fun ClearCloudDataConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Clear All Synced Data?") },
text = { Text("Are you sure you want to permanently delete all of your book data from the cloud? This will also wipe your local library to prevent re-syncing. This action cannot be undone.") },
confirmButton = {
TextButton(
onClick = onConfirm,
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
) {
Text("DELETE ALL DATA")
}
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
}
)
}
@Composable
fun AutoSizeText(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
maxLines: Int = 1,
) {
var scaledTextStyle by remember(text, style) { mutableStateOf(style) }
var readyToDraw by remember(text, style) { mutableStateOf(false) }
Text(
text = text,
modifier = modifier.drawWithContent {
if (readyToDraw) {
drawContent()
}
},
style = scaledTextStyle,
maxLines = maxLines,
softWrap = false,
onTextLayout = { textLayoutResult ->
if (textLayoutResult.hasVisualOverflow) {
scaledTextStyle = scaledTextStyle.copy(
fontSize = scaledTextStyle.fontSize * 0.95
)
} else {
readyToDraw = true
}
}
)
}

View file

@ -0,0 +1,172 @@
// AppDatabase.kt
package com.aryan.reader.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [RecentFileEntity::class, CustomFontEntity::class], version = 12, exportSchema = false)
@TypeConverters(FileTypeConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun recentFileDao(): RecentFileDao
abstract fun customFontDao(): CustomFontDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN lastChapterIndex INTEGER")
db.execSQL("ALTER TABLE recent_files ADD COLUMN lastScrollYPosition INTEGER")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN lastPositionCfi TEXT")
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN progressPercentage REAL")
}
}
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN isRecent INTEGER NOT NULL DEFAULT 1")
}
}
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE recent_files_new (
bookId TEXT NOT NULL PRIMARY KEY,
uriString TEXT,
type TEXT NOT NULL,
displayName TEXT NOT NULL,
timestamp INTEGER NOT NULL,
coverImagePath TEXT,
title TEXT,
author TEXT,
lastChapterIndex INTEGER,
lastScrollYPosition INTEGER,
lastPage INTEGER,
lastPositionCfi TEXT,
progressPercentage REAL,
isRecent INTEGER NOT NULL DEFAULT 1,
isAvailable INTEGER NOT NULL DEFAULT 1
)
""")
db.execSQL("""
INSERT INTO recent_files_new (bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastScrollYPosition, lastPositionCfi, progressPercentage, isRecent)
SELECT uriString, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastScrollYPosition, lastPositionCfi, progressPercentage, isRecent FROM recent_files
""")
db.execSQL("DROP TABLE recent_files")
db.execSQL("ALTER TABLE recent_files_new RENAME TO recent_files")
}
}
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN lastModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE recent_files ADD COLUMN isDeleted INTEGER NOT NULL DEFAULT 0")
}
}
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN locatorBlockIndex INTEGER")
db.execSQL("ALTER TABLE recent_files ADD COLUMN locatorCharOffset INTEGER")
db.execSQL("""
CREATE TABLE recent_files_new (
bookId TEXT NOT NULL PRIMARY KEY, uriString TEXT, type TEXT NOT NULL,
displayName TEXT NOT NULL, timestamp INTEGER NOT NULL, coverImagePath TEXT,
title TEXT, author TEXT, lastChapterIndex INTEGER, lastPage INTEGER,
lastPositionCfi TEXT, progressPercentage REAL,
isRecent INTEGER NOT NULL DEFAULT 1,
isAvailable INTEGER NOT NULL DEFAULT 1,
lastModifiedTimestamp INTEGER NOT NULL DEFAULT 0,
isDeleted INTEGER NOT NULL DEFAULT 0,
locatorBlockIndex INTEGER, locatorCharOffset INTEGER
)
""")
db.execSQL("""
INSERT INTO recent_files_new (
bookId, uriString, type, displayName, timestamp, coverImagePath, title, author,
lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent,
isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset
)
SELECT
bookId, uriString, type, displayName, timestamp, coverImagePath, title, author,
lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent,
isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset
FROM recent_files
""")
db.execSQL("DROP TABLE recent_files")
db.execSQL("ALTER TABLE recent_files_new RENAME TO recent_files")
}
}
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN bookmarks TEXT")
}
}
val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN sourceFolderUri TEXT DEFAULT NULL")
}
}
val MIGRATION_11_12 = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS `custom_fonts` (
`id` TEXT NOT NULL,
`displayName` TEXT NOT NULL,
`fileName` TEXT NOT NULL,
`fileExtension` TEXT NOT NULL,
`path` TEXT NOT NULL,
`timestamp` INTEGER NOT NULL,
`isDeleted` INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(`id`)
)
""")
}
}
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"reader_database"
)
// 4. Add migration to builder
.addMigrations(
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12
)
.fallbackToDestructiveMigration(false)
.build()
INSTANCE = instance
instance
}
}
}
}

View file

@ -0,0 +1,35 @@
// CustomFontDao.kt
package com.aryan.reader.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface CustomFontDao {
@Query("SELECT * FROM custom_fonts WHERE isDeleted = 0 ORDER BY displayName ASC")
fun getAllFonts(): Flow<List<CustomFontEntity>>
@Query("SELECT * FROM custom_fonts WHERE isDeleted = 0")
suspend fun getAllFontsList(): List<CustomFontEntity>
@Query("SELECT * FROM custom_fonts")
suspend fun getAllFontsIncludingDeleted(): List<CustomFontEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertFont(font: CustomFontEntity)
@Query("SELECT * FROM custom_fonts WHERE id = :id")
suspend fun getFontById(id: String): CustomFontEntity?
@Query("UPDATE custom_fonts SET isDeleted = 1 WHERE id = :id")
suspend fun markAsDeleted(id: String)
@Query("DELETE FROM custom_fonts WHERE id = :id")
suspend fun deletePermanently(id: String)
@Query("SELECT * FROM custom_fonts WHERE fileName = :fileName LIMIT 1")
suspend fun getFontByFileName(fileName: String): CustomFontEntity?
}

View file

@ -0,0 +1,16 @@
package com.aryan.reader.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "custom_fonts")
data class CustomFontEntity(
@PrimaryKey val id: String, // UUID
val displayName: String,
val fileName: String, // The actual filename on disk (e.g., font_uuid.ttf)
val fileExtension: String, // ttf, otf, woff2
val path: String, // Absolute path to the file
val timestamp: Long,
@ColumnInfo(defaultValue = "0") val isDeleted: Boolean = false
)

View file

@ -0,0 +1,17 @@
// FileTypeConverter.kt
package com.aryan.reader.data
import androidx.room.TypeConverter
import com.aryan.reader.FileType
class FileTypeConverter {
@TypeConverter
fun fromFileType(fileType: FileType?): String? {
return fileType?.name
}
@TypeConverter
fun toFileType(name: String?): FileType? {
return name?.let { FileType.valueOf(it) }
}
}

View file

@ -0,0 +1,137 @@
// FontsRepository.kt
package com.aryan.reader.data
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
private const val FONTS_DIR = "custom_fonts"
class FontsRepository(private val context: Context) {
private val fontDao = AppDatabase.getDatabase(context).customFontDao()
private val fontsDir = File(context.filesDir, FONTS_DIR)
init {
if (!fontsDir.exists()) {
fontsDir.mkdirs()
}
}
fun getAllFonts(): Flow<List<CustomFontEntity>> = fontDao.getAllFonts()
suspend fun getAllFontsForSync(): List<CustomFontEntity> = fontDao.getAllFontsIncludingDeleted()
@Suppress("unused")
suspend fun getFontById(id: String): CustomFontEntity? = fontDao.getFontById(id)
// Used when downloading from cloud
fun getFontFile(fileName: String): File {
return File(fontsDir, fileName)
}
suspend fun addFontFromSync(metadata: FontMetadata) = withContext(Dispatchers.IO) {
val fontFile = File(fontsDir, metadata.fileName)
val entity = CustomFontEntity(
id = metadata.id,
displayName = metadata.displayName,
fileName = metadata.fileName,
fileExtension = metadata.fileExtension,
path = fontFile.absolutePath,
timestamp = metadata.timestamp,
isDeleted = metadata.isDeleted
)
fontDao.insertFont(entity)
}
suspend fun importFont(uri: Uri): Result<CustomFontEntity> = withContext(Dispatchers.IO) {
try {
val contentResolver = context.contentResolver
val originalName = getFileName(uri) ?: "unknown.ttf"
val extension = originalName.substringAfterLast('.', "").lowercase()
if (extension !in listOf("ttf", "otf", "woff2")) {
return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2."))
}
val fontId = UUID.randomUUID().toString()
val internalFileName = "font_${fontId}.$extension"
val destinationFile = File(fontsDir, internalFileName)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destinationFile).use { output ->
input.copyTo(output)
}
}
val displayName = originalName.substringBeforeLast('.')
val entity = CustomFontEntity(
id = fontId,
displayName = displayName,
fileName = internalFileName,
fileExtension = extension,
path = destinationFile.absolutePath,
timestamp = System.currentTimeMillis()
)
fontDao.insertFont(entity)
Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}")
Result.success(entity)
} catch (e: Exception) {
Timber.e(e, "Failed to import font")
Result.failure(e)
}
}
suspend fun deleteFont(fontId: String) = withContext(Dispatchers.IO) {
val font = fontDao.getFontById(fontId) ?: return@withContext
fontDao.markAsDeleted(fontId)
// We delete the file locally to save space, but keep the DB entry as tombstone for sync
val file = File(font.path)
if (file.exists()) {
file.delete()
}
Timber.d("Deleted font locally: ${font.displayName}")
}
suspend fun deletePermanently(fontId: String) = withContext(Dispatchers.IO) {
val font = fontDao.getFontById(fontId)
font?.let {
val file = File(it.path)
if(file.exists()) file.delete()
}
fontDao.deletePermanently(fontId)
}
private fun getFileName(uri: Uri): String? {
var result: String? = null
if (uri.scheme == "content") {
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index != -1) {
result = it.getString(index)
}
}
}
}
if (result == null) {
result = uri.path
val cut = result?.lastIndexOf('/')
if (cut != -1) {
result = result?.substring(cut!! + 1)
}
}
return result
}
}

View file

@ -0,0 +1,25 @@
package com.aryan.reader.data
/**
* Agnostic representation of a purchase to decouple MainViewModel from Billing Library.
*/
data class PurchaseEntity(
val orderId: String?,
val products: List<String>,
val purchaseToken: String,
val purchaseTime: Long,
val isAcknowledged: Boolean,
val isAutoRenewing: Boolean
)
/**
* Agnostic representation of product details.
*/
data class ProductDetailsEntity(
val productId: String,
val name: String,
val description: String,
val formattedPrice: String,
val currencyCode: String,
val priceAmountMicros: Long
)

View file

@ -0,0 +1,59 @@
// RecentFileDao.kt
package com.aryan.reader.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface RecentFileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateFile(file: RecentFileEntity)
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileEntity>>
@Query("SELECT * FROM recent_files")
suspend fun getAllFiles(): List<RecentFileEntity>
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List<RecentFileEntity>
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
suspend fun deleteFilePermanently(bookIds: List<String>)
@Query("UPDATE recent_files SET isDeleted = 1, isAvailable = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
suspend fun markAsDeleted(bookIds: List<String>, timestamp: Long)
@Query("SELECT * FROM recent_files WHERE lastModifiedTimestamp > :sinceTimestamp")
suspend fun getModifiedSince(sinceTimestamp: Long): List<RecentFileEntity>
@Query("SELECT COUNT(*) FROM recent_files")
suspend fun count(): Int
@Query("SELECT * FROM recent_files WHERE bookId = :bookId")
suspend fun getFileByBookId(bookId: String): RecentFileEntity?
@Query("SELECT * FROM recent_files WHERE uriString = :uriString")
suspend fun getFileByUri(uriString: String): RecentFileEntity?
@Query("DELETE FROM recent_files")
suspend fun clearAll()
@Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateEpubReadingPosition(bookId: String, cfi: String?, chapterIndex: Int, blockIndex: Int, charOffset: Int, progress: Float, timestamp: Long)
@Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updatePdfReadingPosition(bookId: String, page: Int, progress: Float, timestamp: Long)
@Query("UPDATE recent_files SET bookmarks = :bookmarksJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateBookmarks(bookId: String, bookmarksJson: String, timestamp: Long)
@Query("UPDATE recent_files SET isAvailable = 1, uriString = :uriString, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateBookAvailability(bookId: String, uriString: String, timestamp: Long)
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long)
}

View file

@ -0,0 +1,33 @@
// RecentFileEntity.kt
package com.aryan.reader.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.aryan.reader.FileType
@Entity(tableName = "recent_files")
@TypeConverters(FileTypeConverter::class)
data class RecentFileEntity(
@PrimaryKey val bookId: String,
val uriString: String?,
val type: FileType,
val displayName: String,
val timestamp: Long,
val coverImagePath: String?,
val title: String?,
val author: String?,
@ColumnInfo(name = "lastChapterIndex") val lastChapterIndex: Int?,
val lastPage: Int?,
@ColumnInfo(name = "lastPositionCfi") val lastPositionCfi: String?,
@ColumnInfo(name = "progressPercentage") val progressPercentage: Float?,
@ColumnInfo(defaultValue = "1") val isRecent: Boolean,
@ColumnInfo(defaultValue = "1") val isAvailable: Boolean,
val lastModifiedTimestamp: Long,
@ColumnInfo(defaultValue = "0") val isDeleted: Boolean,
val locatorBlockIndex: Int?,
val locatorCharOffset: Int?,
val bookmarks: String?,
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?
)

View file

@ -0,0 +1,126 @@
// RecentFileItem.kt
package com.aryan.reader.data
import android.net.Uri
import com.aryan.reader.FileType
import androidx.core.net.toUri
data class RecentFileItem(
val bookId: String,
val uriString: String?,
val type: FileType,
val displayName: String,
val timestamp: Long,
val coverImagePath: String? = null,
val title: String? = null,
val author: String? = null,
val lastChapterIndex: Int? = null,
val lastPage: Int? = null,
val lastPositionCfi: String? = null,
val locatorBlockIndex: Int? = null,
val locatorCharOffset: Int? = null,
val progressPercentage: Float? = null,
val isRecent: Boolean = true,
val isAvailable: Boolean = true,
val lastModifiedTimestamp: Long = 0L,
val isDeleted: Boolean = false,
val bookmarksJson: String? = null,
val sourceFolderUri: String? = null
) {
fun getUri(): Uri? = uriString?.toUri()
}
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
uriString = this.uriString,
type = this.type,
displayName = this.displayName,
timestamp = this.timestamp,
coverImagePath = this.coverImagePath,
title = this.title,
author = this.author,
lastChapterIndex = this.lastChapterIndex,
locatorBlockIndex = this.locatorBlockIndex,
locatorCharOffset = this.locatorCharOffset,
lastPage = this.lastPage,
lastPositionCfi = this.lastPositionCfi,
progressPercentage = this.progressPercentage,
isRecent = this.isRecent,
isAvailable = this.isAvailable,
lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = this.isDeleted,
bookmarksJson = this.bookmarks,
sourceFolderUri = this.sourceFolderUri
)
}
fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
return RecentFileEntity(
bookId = this.bookId,
uriString = this.uriString,
type = this.type,
displayName = this.displayName,
timestamp = this.timestamp,
coverImagePath = this.coverImagePath,
title = this.title,
author = this.author,
lastChapterIndex = this.lastChapterIndex,
locatorBlockIndex = this.locatorBlockIndex,
locatorCharOffset = this.locatorCharOffset,
lastPage = this.lastPage,
lastPositionCfi = this.lastPositionCfi,
progressPercentage = this.progressPercentage,
isRecent = this.isRecent,
isAvailable = this.isAvailable,
lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = this.isDeleted,
bookmarks = this.bookmarksJson,
sourceFolderUri = this.sourceFolderUri
)
}
fun RecentFileItem.toBookMetadata(): BookMetadata {
return BookMetadata(
bookId = this.bookId,
title = this.title,
author = this.author,
displayName = this.displayName,
type = this.type.name,
lastPositionCfi = this.lastPositionCfi,
lastChapterIndex = this.lastChapterIndex,
locatorBlockIndex = this.locatorBlockIndex,
locatorCharOffset = this.locatorCharOffset,
lastPage = this.lastPage,
progressPercentage = this.progressPercentage,
isRecent = this.isRecent,
isDeleted = this.isDeleted,
lastModifiedTimestamp = this.lastModifiedTimestamp,
bookmarksJson = this.bookmarksJson,
hasAnnotations = false
)
}
fun BookMetadata.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
uriString = null,
type = try { FileType.valueOf(this.type) } catch (_: Exception) { FileType.EPUB },
displayName = this.displayName,
timestamp = this.lastModifiedTimestamp,
coverImagePath = null,
title = this.title,
author = this.author,
lastChapterIndex = this.lastChapterIndex,
locatorBlockIndex = this.locatorBlockIndex,
locatorCharOffset = this.locatorCharOffset,
lastPositionCfi = this.lastPositionCfi,
lastPage = this.lastPage,
progressPercentage = this.progressPercentage,
isRecent = this.isRecent,
isAvailable = false,
lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = this.isDeleted,
bookmarksJson = this.bookmarksJson
)
}

View file

@ -0,0 +1,201 @@
// RecentFilesRepository.kt
package com.aryan.reader.data
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import timber.log.Timber
import com.aryan.reader.BookImporter
import com.aryan.reader.paginatedreader.Locator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
private const val COVER_CACHE_DIR = "cover_cache"
class RecentFilesRepository(context: Context) {
private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao()
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
private val bookImporter = BookImporter(context)
init {
if (!coverCacheDir.exists()) {
coverCacheDir.mkdirs()
}
}
fun getRecentFilesFlow(): Flow<List<RecentFileItem>> {
return recentFileDao.getRecentFiles().map { entities ->
entities.map { it.toRecentFileItem() }
}
}
suspend fun getFileByBookId(bookId: String): RecentFileItem? = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getFileByBookId(bookId)?.toRecentFileItem()
}
suspend fun getFileByUri(uriString: String): RecentFileItem? = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem()
}
suspend fun getAllFilesForSync(): List<RecentFileItem> = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() }
}
suspend fun clearAllLocalData() = withContext(Dispatchers.IO) {
recentFileDao.clearAll()
if (coverCacheDir.exists()) {
coverCacheDir.deleteRecursively()
}
coverCacheDir.mkdirs()
Timber.d("Cleared all local book data and cover cache.")
}
suspend fun addRecentFile(item: RecentFileItem) = withContext(Dispatchers.IO) {
Timber.d("SyncDebug: addRecentFile called for bookId: ${item.bookId}")
Timber.d("SyncDebug: -> Incoming item: title='${item.title}', uri='${item.uriString}', isAvailable=${item.isAvailable}, isDeleted=${item.isDeleted}, isRecent=${item.isRecent}")
val existingItem = recentFileDao.getFileByBookId(item.bookId)
Timber.d("SyncDebug: -> Existing item found: ${existingItem != null}")
if (existingItem != null) {
Timber.d("SyncDebug: -> Existing item details: title='${existingItem.title}', uri='${existingItem.uriString}', isAvailable=${existingItem.isAvailable}, isRecent=${existingItem.isRecent}")
}
val entityToInsert = if (existingItem != null) {
item.toRecentFileEntity().copy(
uriString = existingItem.uriString ?: item.uriString,
isAvailable = existingItem.isAvailable || item.isAvailable,
coverImagePath = item.coverImagePath ?: existingItem.coverImagePath,
title = item.title ?: existingItem.title,
author = item.author ?: existingItem.author,
lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
lastPage = item.lastPage ?: existingItem.lastPage,
lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
locatorBlockIndex = item.locatorBlockIndex ?: existingItem.locatorBlockIndex,
locatorCharOffset = item.locatorCharOffset ?: existingItem.locatorCharOffset,
bookmarks = item.bookmarksJson ?: existingItem.bookmarks,
progressPercentage = item.progressPercentage ?: existingItem.progressPercentage,
isRecent = item.isRecent,
isDeleted = item.isDeleted,
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri
)
} else {
item.toRecentFileEntity()
}
Timber.d("SyncDebug: -> Final entity to insert: uri='${entityToInsert.uriString}', isAvailable=${entityToInsert.isAvailable}, isDeleted=${entityToInsert.isDeleted}, isRecent=${entityToInsert.isRecent}")
recentFileDao.insertOrUpdateFile(entityToInsert)
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
}
suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) {
val item = recentFileDao.getFileByUri(uriString)
if (item != null) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateEpubReadingPosition(
bookId = item.bookId,
cfi = cfiForWebView,
chapterIndex = locator.chapterIndex,
blockIndex = locator.blockIndex,
charOffset = locator.charOffset,
progress = progress,
timestamp = currentTime
)
Timber.d("Updated EPUB reading position for ${item.bookId} to Locator: $locator, Progress: $progress%")
}
}
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
Timber.d("Updated bookmarks for $bookId")
}
suspend fun updatePdfReadingPosition(uriString: String, page: Int, progress: Float) = withContext(Dispatchers.IO) {
val item = recentFileDao.getFileByUri(uriString)
if (item != null) {
val currentTime = System.currentTimeMillis()
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
Timber.d("Updated PDF reading position for ${item.bookId} to page $page, progress $progress%")
}
}
@Suppress("unused")
suspend fun makeBookAvailable(bookId: String, internalUri: Uri) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateBookAvailability(bookId, internalUri.toString(), currentTime)
Timber.d("Made book available locally: $bookId at URI $internalUri")
}
suspend fun markAsNotRecent(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isNotEmpty()) {
Timber.d("DeleteDebug: DAO - Marking ${bookIds.size} items as not recent.")
recentFileDao.markAsNotRecent(bookIds, System.currentTimeMillis())
}
}
suspend fun markAsDeleted(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isNotEmpty()) {
recentFileDao.markAsDeleted(bookIds, System.currentTimeMillis())
Timber.d("DeleteDebug: DAO - Marked ${bookIds.size} items as deleted.")
}
}
suspend fun deleteFilePermanently(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isEmpty()) return@withContext
val itemsToRemove = bookIds.mapNotNull { recentFileDao.getFileByBookId(it) }
if (itemsToRemove.isNotEmpty()) {
Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.")
itemsToRemove.forEach { item ->
item.coverImagePath?.let { deleteCachedCover(it) }
item.uriString?.let { bookImporter.deleteBookByUriString(it) }
}
recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId })
Timber.d("Permanently removed recent files from DB.")
} else {
Timber.w("DeleteDebug: DAO - Files not found for permanent deletion.")
}
}
private fun getCoverCacheDirInternal(): File {
if (!coverCacheDir.exists()) {
coverCacheDir.mkdirs()
}
return coverCacheDir
}
suspend fun saveCoverToCache(bitmap: Bitmap, uri: Uri): String? = withContext(Dispatchers.IO) {
val cacheDir = getCoverCacheDirInternal()
val filename = "cover_${uri.toString().hashCode()}.png"
val file = File(cacheDir, filename)
var fos: FileOutputStream? = null
try {
fos = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 90, fos)
Timber.d("Saved cover image to: ${file.absolutePath}")
return@withContext file.absolutePath
} catch (e: Exception) {
Timber.e(e, "Failed to save cover image to cache for $uri")
file.delete()
return@withContext null
} finally {
fos?.close()
}
}
private fun deleteCachedCover(filePath: String): Boolean {
val file = File(filePath)
val deleted = file.delete()
if (deleted) {
Timber.d("Deleted cached cover: $filePath")
} else {
Timber.w("Failed to delete cached cover: $filePath")
}
return deleted
}
}

View file

@ -0,0 +1,35 @@
package com.aryan.reader.epub
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import kotlinx.serialization.KSerializer
import kotlinx.serialization.builtins.ByteArraySerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.io.ByteArrayOutputStream
/**
* A [KSerializer] for [Bitmap] objects.
* It serializes the bitmap to a byte array and deserializes it back to a bitmap.
*/
object BitmapSerializer : KSerializer<Bitmap> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Bitmap") {
element<ByteArray>("bytes")
}
override fun serialize(encoder: Encoder, value: Bitmap) {
val stream = ByteArrayOutputStream()
value.compress(Bitmap.CompressFormat.PNG, 100, stream)
val byteArray = stream.toByteArray()
encoder.encodeSerializableValue(ByteArraySerializer(), byteArray)
}
override fun deserialize(decoder: Decoder): Bitmap {
val byteArray = decoder.decodeSerializableValue(ByteArraySerializer())
return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size)
}
}

View file

@ -0,0 +1,31 @@
package com.aryan.reader.epub
import android.graphics.Bitmap
import com.aryan.reader.epub.EpubParser.EpubPageTarget
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
@Serializable
data class EpubTocEntry(
val label: String,
val absolutePath: String,
val fragmentId: String?,
val depth: Int
)
@Serializable
data class EpubBook(
val fileName: String,
val title: String,
val author: String,
val language: String,
@Serializable(with = BitmapSerializer::class) val coverImage: Bitmap?,
val chapters: List<EpubChapter> = emptyList(),
val images: List<EpubImage> = emptyList(),
val pageList: List<EpubPageTarget> = emptyList(),
val tableOfContents: List<EpubTocEntry> = emptyList(),
val extractionBasePath: String = "",
val css: Map<String, String> = emptyMap(),
@Transient
val chaptersForPagination: List<EpubChapter> = chapters
)

View file

@ -0,0 +1,19 @@
// EpubChapter.kt
package com.aryan.reader.epub
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class EpubChapter @OptIn(ExperimentalSerializationApi::class) constructor(
@ProtoNumber(1) val chapterId: String,
@ProtoNumber(2) val absPath: String,
@ProtoNumber(3) val title: String,
@ProtoNumber(4) val htmlFilePath: String,
@ProtoNumber(5) val plainTextContent: String,
@ProtoNumber(6) val htmlContent: String,
@ProtoNumber(7) val depth: Int = 0,
@ProtoNumber(8) val isInToc: Boolean = true
)

View file

@ -0,0 +1,36 @@
package com.aryan.reader.epub
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
/**
* Represents an image in an epub book.
*
* @param absPath The absolute path of the image.
* @param image The image data.
*/
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class EpubImage @OptIn(ExperimentalSerializationApi::class) constructor(
@ProtoNumber(1) val absPath: String,
@ProtoNumber(2) val image: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as EpubImage
if (absPath != other.absPath) return false
if (!image.contentEquals(other.image)) return false
return true
}
override fun hashCode(): Int {
var result = absPath.hashCode()
result = 31 * result + image.contentHashCode()
return result
}
}

View file

@ -0,0 +1,662 @@
// EpubParser.kt
package com.aryan.reader.epub
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import timber.log.Timber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import org.jsoup.Jsoup
import org.w3c.dom.Element
import org.w3c.dom.Node
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.net.URLDecoder
import java.nio.file.Paths
import java.util.UUID
import java.util.zip.ZipFile
class EpubParser(private val context: Context) {
data class EpubDocument(
val metadata: Node, val manifest: Node, val spine: Node, val opfFilePath: String
)
data class EpubManifestItem(
val id: String, val absPath: String, val mediaType: String, val properties: String
)
data class TempEpubChapter(
val url: String,
val title: String?,
val htmlFilePath: String,
val chapterIndex: Int,
val plainTextContent: String,
val htmlContent: String,
val depth: Int,
val isInToc: Boolean
)
// Helper class for NCX parsing results
data class NcxMetadata(
val title: String,
val depth: Int
)
// EpubFile can still represent in-memory file data during initial parsing before extraction
data class EpubFile(val absPath: String, val data: ByteArray) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as EpubFile
if (absPath != other.absPath) return false
return data.contentEquals(other.data)
}
override fun hashCode(): Int {
var result = absPath.hashCode()
result = 31 * result + data.contentHashCode()
return result
}
}
@Serializable
data class EpubPageTarget(
val id: String?,
val value: String?,
val label: String?,
val contentSrc: String
)
companion object {
const val TAG = "EpubParser"
internal const val EXTRACTED_EPUB_DIR_NAME = "extracted_epubs"
}
internal val String.decodedURL: String
get() = try {
URLDecoder.decode(this, "UTF-8")
} catch (e: Exception) {
Timber.w(e, "Failed to decode URL: $this")
this
}
private fun getBookExtractionDir(bookIdentifier: String): File {
val parentDir = File(context.cacheDir, EXTRACTED_EPUB_DIR_NAME)
if (!parentDir.exists()) {
parentDir.mkdirs()
}
return File(parentDir, bookIdentifier)
}
private fun parsePageList(pageListElement: Element?, ncxFileParentDir: File): List<EpubPageTarget> {
if (pageListElement == null) {
Timber.d("No <pageList> element found in NCX.")
return emptyList()
}
val pageTargets = mutableListOf<EpubPageTarget>()
pageListElement.selectChildTag("pageTarget").forEach { ptElement ->
val contentSrcRaw = ptElement.selectFirstChildTag("content")?.getAttributeValue("src")?.decodedURL
if (contentSrcRaw != null) {
val contentPathRelativeToEpubRoot = Paths.get(ncxFileParentDir.path, contentSrcRaw)
.normalize().toString().replace(File.separatorChar, '/')
pageTargets.add(
EpubPageTarget(
id = ptElement.getAttributeValue("id"),
value = ptElement.getAttributeValue("value"),
label = ptElement.selectFirstChildTag("navLabel")?.selectFirstChildTag("text")?.textContent,
contentSrc = contentPathRelativeToEpubRoot
)
)
} else {
Timber.w("PageTarget found with no content src: ${ptElement.getAttributeValue("id")}")
}
}
Timber.d("Parsed ${pageTargets.size} page targets from NCX.")
return pageTargets
}
private fun parseEpubCss(
manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,
extractionRoot: File
): Map<String, String> {
val listedCss = manifestItems.values
.filter { it.mediaType == "text/css" }
.mapNotNull { manifestItem ->
val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
bytes?.let { manifestItem.absPath to String(it, Charsets.UTF_8) }
}
val listedCssPaths = listedCss.map { it.first }.toSet()
val unlistedCss = filesContentMap.asSequence()
.filter { (path, _) -> path.endsWith(".css", ignoreCase = true) }
.filterNot { (path, _) -> listedCssPaths.contains(path) }
.mapNotNull { (path, file) ->
val bytes = file.data.takeIf { it.isNotEmpty() }
?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
bytes?.let { path to String(it, Charsets.UTF_8) }
}
.toList()
val allCss = (listedCss + unlistedCss).toMap()
Timber.d("Parsed ${allCss.size} CSS files (listed: ${listedCss.size}, unlisted: ${unlistedCss.size}): ${allCss.keys.joinToString()}")
return allCss
}
suspend fun createEpubBook(
inputStream: InputStream,
shouldUseToc: Boolean = true,
originalBookNameHint: String = "streamed_book",
parseContent: Boolean = true
): EpubBook {
return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream")
val bookIdentifier = originalBookNameHint.asFileName() + "_" + UUID.randomUUID().toString().substring(0, 8)
val extractionDir = getBookExtractionDir(bookIdentifier)
if (extractionDir.exists()) {
extractionDir.deleteRecursively()
}
extractionDir.mkdirs()
val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir)
val filesMap: Map<String, EpubFile>
try {
tempFile.outputStream().use { output ->
inputStream.copyTo(output)
}
filesMap = extractEpubContents(ZipFile(tempFile), extractionDir, parseContent)
} finally {
tempFile.delete()
}
val document = createEpubDocument(filesMap)
val book = parseAndCreateEbook(filesMap, document, shouldUseToc, extractionDir.absolutePath,
bookIdentifier, parseContent)
return@withContext book
}
}
private fun extractEpubContents(zipFile: ZipFile, extractionDir: File, parseContent: Boolean): Map<String, EpubFile> {
val filesMap = mutableMapOf<String, EpubFile>()
zipFile.use { zf ->
zf.entries().asSequence().filterNot { it.isDirectory }.forEach { entry ->
val outputFile = File(extractionDir, entry.name)
outputFile.parentFile?.mkdirs()
zf.getInputStream(entry).use { input ->
FileOutputStream(outputFile).use { output ->
input.copyTo(output)
}
}
val data = if (isEssentialFile(entry.name, parseContent)) {
outputFile.readBytes()
} else {
ByteArray(0)
}
filesMap[entry.name] = EpubFile(absPath = entry.name, data = data)
}
}
return filesMap
}
private suspend fun parseAndCreateEbook(
filesContentMap: Map<String, EpubFile>,
document: EpubDocument,
shouldUseToc: Boolean,
extractionBasePath: String,
originalFilePathOrKey: String,
parseContent: Boolean = true
): EpubBook = withContext(Dispatchers.IO) {
val metadataTitle =
document.metadata.selectFirstChildTag("dc:title")?.textContent ?: File(originalFilePathOrKey).nameWithoutExtension
val metadataAuthor =
document.metadata.selectFirstChildTag("dc:creator")?.textContent ?: "Unknown Author"
val metadataLanguage =
document.metadata.selectFirstChildTag("dc:language")?.textContent ?: "en"
val metadataCoverId = getMetadataCoverId(document.metadata)
val opfRelativePath = document.opfFilePath
val opfParentDir = File(opfRelativePath).parentFile ?: File("")
val manifestItems = getManifestItems(document.manifest, opfParentDir)
var pageTargets: List<EpubPageTarget> = emptyList()
val ncxMetadataMap = mutableMapOf<String, NcxMetadata>()
val extractionRoot = File(extractionBasePath)
if (shouldUseToc) {
Timber.d("shouldUseToc is true. Attempting to parse NCX.")
val tocFileItem = manifestItems.values.firstOrNull {
it.absPath.endsWith(".ncx", ignoreCase = true)
}
if (tocFileItem != null) {
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
if (tocDocumentNode != null) {
Timber.d("Successfully parsed NCX file: ${tocFileItem.absPath}")
val pageListElement = tocDocumentNode.selectFirstTag("pageList") as Element?
pageTargets = parsePageList(pageListElement, ncxParentDir)
val navMapElement = tocDocumentNode.selectFirstTag("navMap") as Element?
if (navMapElement != null) {
// Recursively parse navMap
ncxMetadataMap.putAll(parseNavMapRecursive(navMapElement, ncxParentDir))
} else {
Timber.d("No <navMap> element found in NCX.")
}
} else {
Timber.w("NCX file item '${tocFileItem.absPath}' found in manifest but could not be parsed.")
}
} else {
Timber.d("No NCX file found in manifest. Skipping NCX-based PageList/NavMap.")
}
} else {
Timber.d("shouldUseToc is false. Skipping NCX parsing for PageList/NavMap.")
}
Timber.d("Parsing chapters based on OPF spine for rendering order. NCX titles/depth will be used if available.")
val tableOfContents = if (shouldUseToc) {
val tocFileItem = manifestItems.values.firstOrNull {
it.absPath.endsWith(".ncx", ignoreCase = true)
}
if (tocFileItem != null) {
val ncxParentDir = File(tocFileItem.absPath).parentFile ?: File("")
val ncxData = filesContentMap[tocFileItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, tocFileItem.absPath).takeIf { it.exists() }?.readBytes()
val tocDocumentNode = ncxData?.let { parseXMLFile(it) }
val navMapElement = tocDocumentNode?.selectFirstTag("navMap") as Element?
if (navMapElement != null) {
parseTableOfContents(navMapElement, ncxParentDir)
} else {
emptyList()
}
} else {
emptyList()
}
} else {
emptyList()
}
val chaptersFromSpine = if (parseContent) {
parseUsingSpine(document.spine, manifestItems, filesContentMap, ncxMetadataMap)
} else {
emptyList()
}
Timber.d("Parsing images (for cover and general access)")
val images = if (parseContent) {
parseEpubImages(manifestItems, filesContentMap, extractionRoot)
} else {
emptyList()
}
Timber.d("Parsing cover image")
val coverImage = parseCoverImage(metadataCoverId, manifestItems, filesContentMap, extractionRoot)
val cssContent = if (parseContent) {
parseEpubCss(manifestItems, filesContentMap, extractionRoot)
} else {
emptyMap()
}
Timber.d("EpubBook created with ${chaptersFromSpine.size} spine chapters.")
return@withContext EpubBook(
fileName = metadataTitle.asFileName(),
title = metadataTitle,
author = metadataAuthor,
language = metadataLanguage,
coverImage = coverImage,
chapters = chaptersFromSpine, chaptersForPagination = chaptersFromSpine,
images = images,
pageList = pageTargets,
tableOfContents = tableOfContents,
extractionBasePath = extractionBasePath,
css = cssContent
)
}
private fun parseTableOfContents(
navMapElement: Element,
ncxParentDir: File
): List<EpubTocEntry> {
val result = mutableListOf<EpubTocEntry>()
fun recurse(element: Element, currentDepth: Int) {
val navPoints = element.childElements.filter { it.tagName == "navPoint" }
for (navPoint in navPoints) {
val label = navPoint.selectFirstChildTag("navLabel")
?.selectFirstChildTag("text")?.textContent?.trim() ?: "Untitled"
val contentSrc = navPoint.selectFirstChildTag("content")
?.getAttributeValue("src")?.decodedURL
if (contentSrc != null) {
val fullPathRaw = Paths.get(ncxParentDir.path, contentSrc)
.normalize().toString().replace(File.separatorChar, '/')
val parts = fullPathRaw.split("#", limit = 2)
val absolutePath = parts[0]
val fragmentId = if (parts.size > 1) parts[1] else null
result.add(EpubTocEntry(label, absolutePath, fragmentId, currentDepth))
recurse(navPoint, currentDepth + 1)
}
}
}
recurse(navMapElement, 0)
return result
}
@Throws(EpubParserException::class)
private fun createEpubDocument(files: Map<String, EpubFile>): EpubDocument {
val containerFile = files["META-INF/container.xml"]
?: throw EpubParserException("META-INF/container.xml file missing")
val rawOpfPath = parseXMLFile(containerFile.data)?.selectFirstTag("rootfile")
?.getAttributeValue("full-path")?.decodedURL
?: throw EpubParserException("Invalid container.xml: Could not find rootfile full-path.")
val opfFilePath = rawOpfPath.trimStart('/')
val opfFile = files[opfFilePath]
?: throw EpubParserException(".opf file missing at normalized path '$opfFilePath'.")
val document = parseXMLFile(opfFile.data)
?: throw EpubParserException(".opf file failed to parse data from '$opfFilePath'")
val metadata = document.selectFirstTag("metadata")
?: document.selectFirstTag("opf:metadata")
?: throw EpubParserException(".opf file metadata section missing in '$opfFilePath'")
val manifest = document.selectFirstTag("manifest")
?: document.selectFirstTag("opf:manifest")
?: throw EpubParserException(".opf file manifest section missing in '$opfFilePath'")
val spine = document.selectFirstTag("spine")
?: document.selectFirstTag("opf:spine")
?: throw EpubParserException(".opf file spine section missing in '$opfFilePath'")
return EpubDocument(metadata, manifest, spine, opfFilePath)
}
private fun getMetadataCoverId(metadata: Node): String? {
return metadata.selectChildTag("meta")
.ifEmpty { metadata.selectChildTag("opf:meta") }
.find { it.getAttributeValue("name") == "cover" }?.getAttributeValue("content")
}
private fun getManifestItems(
manifest: Node,
opfParentDir: File
): Map<String, EpubManifestItem> {
return manifest.selectChildTag("item")
.ifEmpty { manifest.selectChildTag("opf:item") }
.mapNotNull { itemElement ->
val href = itemElement.getAttribute("href")?.decodedURL ?: return@mapNotNull null
val pathRelativeToEpubRoot = Paths.get(opfParentDir.path, href)
.normalize().toString().replace(File.separatorChar, '/')
EpubManifestItem(
id = itemElement.getAttribute("id"),
absPath = pathRelativeToEpubRoot,
mediaType = itemElement.getAttribute("media-type"),
properties = itemElement.getAttribute("properties")
)
}.associateBy { it.id }
}
private fun parseNavMapRecursive(
element: Element,
ncxFileParentDir: File,
depth: Int = 0
): Map<String, NcxMetadata> {
val result = mutableMapOf<String, NcxMetadata>()
val navPoints = element.childElements.filter { it.tagName == "navPoint" }
for (navPoint in navPoints) {
val navLabelText = navPoint.selectFirstChildTag("navLabel")
?.selectFirstChildTag("text")?.textContent?.trim()
val contentSrcRaw = navPoint.selectFirstChildTag("content")
?.getAttributeValue("src")?.decodedURL
if (navLabelText != null && contentSrcRaw != null && navLabelText.isNotEmpty()) {
val contentPathRelativeToEpubRoot = Paths.get(ncxFileParentDir.path, contentSrcRaw)
.normalize().toString().replace(File.separatorChar, '/')
.substringBefore('#')
if (!result.containsKey(contentPathRelativeToEpubRoot)) {
result[contentPathRelativeToEpubRoot] = NcxMetadata(navLabelText, depth)
Timber.d("NCX Map: '$contentPathRelativeToEpubRoot' -> '$navLabelText' (Depth $depth)")
}
}
result.putAll(parseNavMapRecursive(navPoint, ncxFileParentDir, depth + 1))
}
return result
}
private fun generateId(): String {
return UUID.randomUUID().toString()
}
private fun parseUsingSpine(
spine: Node,
manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,
ncxMetadataMap: Map<String, NcxMetadata>
): List<EpubChapter> {
var chapterCounter = 0
val tempChapters = mutableListOf<TempEpubChapter>()
spine.selectChildTag("itemref")
.ifEmpty { spine.selectChildTag("opf:itemref") }
.mapNotNull { manifestItems[it.getAttribute("idref")] }
.forEach { item ->
val fileBytes = filesContentMap[item.absPath]?.data
if (fileBytes != null) {
if (item.mediaType.startsWith("application/xhtml+xml") ||
item.mediaType.startsWith("text/html") ||
item.absPath.endsWith(".html", ignoreCase = true) ||
item.absPath.endsWith(".xhtml", ignoreCase = true) ||
item.absPath.endsWith(".xml", ignoreCase = true)
) {
val rawHtml = String(fileBytes, Charsets.UTF_8)
val plainText = Jsoup.parse(rawHtml).text()
val parser = EpubXMLFileParser(
fileRelativePath = item.absPath,
data = fileBytes,
fragmentId = null
)
val res = parser.parseForTitleAndPath()
val chapterTitleFromHtml = res.title
val ncxKey = item.absPath.substringBefore('#')
val ncxData = ncxMetadataMap[ncxKey]
val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) {
ncxData != null
} else {
true
}
val finalChapterTitle = if (ncxData != null && ncxData.title.isNotBlank()) {
ncxData.title
} else {
Timber.d("No NCX title for ${item.absPath}, using HTML title: '$chapterTitleFromHtml'")
chapterTitleFromHtml
}
val finalDepth = ncxData?.depth ?: 0
chapterCounter++
tempChapters.add(
TempEpubChapter(
url = item.absPath,
title = finalChapterTitle,
htmlFilePath = res.effectiveHtmlPath,
chapterIndex = chapterCounter,
plainTextContent = plainText,
htmlContent = rawHtml,
depth = finalDepth,
isInToc = isEffectiveInToc
)
)
} else if (item.mediaType.startsWith("image/")) {
val htmlContent = """
<!DOCTYPE html><html style="margin:0;padding:0;height:100%;"><head><title>Image</title></head><body style="margin:0;padding:0;height:100%;text-align:center;"><img src="${item.absPath}" alt="Image from spine" style="object-fit:contain;width:100%;height:100%;"/></body></html>
""".trimIndent()
val ncxKey = item.absPath.substringBefore('#')
val ncxData = ncxMetadataMap[ncxKey]
val isEffectiveInToc = if (ncxMetadataMap.isNotEmpty()) ncxData != null else true
chapterCounter++
tempChapters.add(
TempEpubChapter(
url = item.absPath,
title = ncxData?.title ?: "Image",
htmlFilePath = item.absPath,
chapterIndex = chapterCounter,
plainTextContent = "[Image]",
htmlContent = htmlContent,
depth = ncxData?.depth ?: 0,
isInToc = isEffectiveInToc
)
)
}
}
}
return tempChapters.map { tempChapter ->
EpubChapter(
chapterId = generateId(),
absPath = tempChapter.url,
title = tempChapter.title?.takeIf { it.isNotBlank() } ?: "Chapter ${tempChapter.chapterIndex}",
htmlFilePath = tempChapter.htmlFilePath,
plainTextContent = tempChapter.plainTextContent,
htmlContent = tempChapter.htmlContent,
depth = tempChapter.depth,
isInToc = tempChapter.isInToc
)
}.filter { it.htmlFilePath.isNotBlank() }
}
private fun parseEpubImages(
manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,
extractionRoot: File // Add this param
): List<EpubImage> {
val imageExtensions = listOf("png", "gif", "jpg", "jpeg", "webp", "svg").map { ".$it" }
val listedImages = manifestItems.values
.filter { it.mediaType.startsWith("image/") }
.mapNotNull { manifestItem ->
val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
bytes?.let { EpubImage(absPath = manifestItem.absPath, image = it) }
}
val unlistedImages = filesContentMap.asSequence()
.filter { (path, _) -> imageExtensions.any { path.endsWith(it, ignoreCase = true) } }
.filterNot { (path, _) -> listedImages.any { it.absPath == path } }
.mapNotNull { (path, file) ->
val bytes = file.data.takeIf { it.isNotEmpty() }
?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
bytes?.let { EpubImage(absPath = path, image = it) }
}
return (listedImages + unlistedImages).distinctBy { it.absPath }.toList()
}
private fun parseCoverImage(
metadataCoverId: String?,
manifestItems: Map<String, EpubManifestItem>,
filesContentMap: Map<String, EpubFile>,
extractionRoot: File
): Bitmap? {
val coverManifestItem = manifestItems[metadataCoverId]
if (coverManifestItem != null) {
val coverImageBytes = filesContentMap[coverManifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, coverManifestItem.absPath).takeIf { it.exists() }?.readBytes()
if (coverImageBytes != null) {
return BitmapFactory.decodeByteArray(coverImageBytes, 0, coverImageBytes.size)
} else {
Timber.e("Cover image file content not found for path: ${coverManifestItem.absPath}")
}
} else {
if (metadataCoverId != null) {
Timber.w("Cover image ID '$metadataCoverId' not found in manifest.")
} else {
Timber.d("No cover image ID specified in metadata.")
}
}
val commonCoverNames = listOf("cover.jpg", "cover.jpeg", "cover.png")
for (name in commonCoverNames) {
val possiblePaths = listOf(
name, "images/$name", "Images/$name", "image/$name", "Image/$name",
"OEBPS/images/$name", "OEBPS/Images/$name", "OEBPS/image/$name", "OEBPS/Image/$name",
"OPS/images/$name", "OPS/Images/$name", "OPS/image/$name", "OPS/Image/$name"
)
for (path in possiblePaths) {
if (filesContentMap.containsKey(path)) {
val bytes = filesContentMap[path]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, path).takeIf { it.exists() }?.readBytes()
bytes?.let {
Timber.d("Found fallback cover image at $path")
return BitmapFactory.decodeByteArray(it, 0, it.size)
}
}
manifestItems.values.find { item -> item.absPath.equals(path, ignoreCase = true) && item.mediaType.startsWith("image/") }?.let { manifestItem ->
val bytes = filesContentMap[manifestItem.absPath]?.data?.takeIf { it.isNotEmpty() }
?: File(extractionRoot, manifestItem.absPath).takeIf { it.exists() }?.readBytes()
bytes?.let {
Timber.d("Found fallback cover image via manifest item (case-insensitive) at ${manifestItem.absPath}")
return BitmapFactory.decodeByteArray(it, 0, it.size)
}
}
}
}
Timber.d("Cover image could not be loaded from metadata or common fallbacks.")
return null
}
private fun isEssentialFile(fileName: String, parseContent: Boolean): Boolean {
val lowerName = fileName.lowercase()
if (lowerName.endsWith("container.xml") || lowerName.endsWith(".opf")) {
return true
}
if (!parseContent) {
return false
}
return lowerName.endsWith(".xml") ||
lowerName.endsWith(".ncx") ||
lowerName.endsWith(".html") ||
lowerName.endsWith(".xhtml") ||
lowerName.endsWith(".htm") ||
lowerName.endsWith(".css")
}
}

View file

@ -0,0 +1,9 @@
package com.aryan.reader.epub
/**
* Exception thrown when an error occurs while parsing an EPUB file.
*
* @param message The error message.
*/
class EpubParserException(message: String) : Exception(message)

View file

@ -0,0 +1,25 @@
package com.aryan.reader.epub
import org.w3c.dom.Document
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.NodeList
import java.io.InputStream
import javax.xml.parsers.DocumentBuilderFactory
fun parseXMLFile(inputSteam: InputStream): Document? =
DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputSteam)
fun parseXMLFile(byteArray: ByteArray): Document? = parseXMLFile(byteArray.inputStream())
fun String.asFileName(): String = this.replace("/", "_")
fun Document.selectFirstTag(tag: String): Node? = getElementsByTagName(tag).item(0)
fun Node.selectFirstChildTag(tag: String) = childElements.find { it.tagName == tag }
fun Node.selectChildTag(tag: String) = childElements.filter { it.tagName == tag }
fun Node.getAttributeValue(attribute: String): String? =
attributes?.getNamedItem(attribute)?.textContent
val NodeList.elements get() = (0..length).asSequence().mapNotNull { item(it) as? Element }
val Node.childElements get() = childNodes.elements

View file

@ -0,0 +1,55 @@
package com.aryan.reader.epub
import timber.log.Timber
import org.jsoup.Jsoup
/**
* Parses an XML/HTML file from an EPUB archive, primarily to extract a title
* and determine the effective path for WebView (including fragments).
*
* @property fileRelativePath The relative path of the HTML file within the EPUB's extraction directory.
* @property data The raw data (content) of the HTML file.
* @property fragmentId Optional ID of the fragment to link to within the HTML file.
*/
class EpubXMLFileParser(
val fileRelativePath: String, // e.g., "OEBPS/chapter1.xhtml"
val data: ByteArray,
private val fragmentId: String? = null
) {
companion object {
private const val TAG = "EpubXMLFileParser"
}
/**
* Represents the output of the parsing.
*
* @property title The extracted title of the HTML document (e.g., from h1-h6 tags).
* @property effectiveHtmlPath The relative path to the HTML file, including any fragment identifier.
* This path is relative to the book's extraction base.
*/
data class Output(val title: String?, val effectiveHtmlPath: String)
/**
* Parses the HTML data to extract a title and construct the effective HTML path.
*
* @return [Output] The title and effective HTML path.
*/
fun parseForTitleAndPath(): Output {
Timber.d("Parsing for title and path: $fileRelativePath, fragment: $fragmentId")
val document = Jsoup.parse(data.inputStream(), "UTF-8", "")
val extractedTitle = document.selectFirst("h1, h2, h3, h4, h5, h6")?.text()?.trim()
val pathWithFragment = if (fragmentId != null) {
"$fileRelativePath#$fragmentId"
} else {
fileRelativePath
}
Timber.d("Effective HTML path: $pathWithFragment for file: $fileRelativePath")
return Output(
title = extractedTitle,
effectiveHtmlPath = pathWithFragment
)
}
}

View file

@ -0,0 +1,295 @@
// MobiParser.kt
package com.aryan.reader.epub
import android.content.Context
import android.graphics.BitmapFactory
import timber.log.Timber
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import java.io.File
import java.io.InputStream
import java.util.UUID
class MobiParser(private val context: Context) {
private data class ParsedMobiTocEntry(val title: String, val filePosition: Int) : Comparable<ParsedMobiTocEntry> {
override fun compareTo(other: ParsedMobiTocEntry): Int = this.filePosition.compareTo(other.filePosition)
}
// Updated data class to receive the full raw HTML
private data class ParsedMobiData(
val title: String?,
val author: String?,
val publisher: String?,
val rawHtmlContent: String?, // This is the full HTML of the book
val resources: Array<ParsedMobiResource>,
val toc: Array<ParsedMobiTocEntry>?,
val coverImageResourceUid: Int // Use -1 if not found
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParsedMobiData
if (title != other.title) return false
if (author != other.author) return false
if (publisher != other.publisher) return false
if (rawHtmlContent != other.rawHtmlContent) return false
if (!resources.contentEquals(other.resources)) return false
if (toc != null) {
if (other.toc == null) return false
if (!toc.contentEquals(other.toc)) return false
} else if (other.toc != null) return false
if (coverImageResourceUid != other.coverImageResourceUid) return false
return true
}
override fun hashCode(): Int {
var result = title?.hashCode() ?: 0
result = 31 * result + (author?.hashCode() ?: 0)
result = 31 * result + (publisher?.hashCode() ?: 0)
result = 31 * result + (rawHtmlContent?.hashCode() ?: 0)
result = 31 * result + resources.contentHashCode()
result = 31 * result + (toc?.contentHashCode() ?: 0)
result = 31 * result + coverImageResourceUid
return result
}
}
private data class ParsedMobiResource(
val uid: Int,
val path: String,
val data: ByteArray,
val mediaType: String
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ParsedMobiResource
if (uid != other.uid) return false
if (path != other.path) return false
if (!data.contentEquals(other.data)) return false
if (mediaType != other.mediaType) return false
return true
}
override fun hashCode(): Int {
var result = uid
result = 31 * result + path.hashCode()
result = 31 * result + data.contentHashCode()
result = 31 * result + mediaType.hashCode()
return result
}
}
private external fun parseMobiFile(filePath: String): ParsedMobiData?
companion object {
private const val TAG = "MobiParser"
private const val AZW3_TAG = "AZW3_DEBUG"
const val EXTRACTED_EPUB_DIR_NAME = "extracted_epubs"
init {
System.loadLibrary("mobi")
System.loadLibrary("native-lib")
}
}
private fun getBookExtractionDir(bookIdentifier: String): File {
val parentDir = File(context.cacheDir, EXTRACTED_EPUB_DIR_NAME)
if (!parentDir.exists()) {
parentDir.mkdirs()
}
return File(parentDir, bookIdentifier)
}
suspend fun createMobiBook(inputStream: InputStream, originalBookNameHint: String): EpubBook? = withContext(Dispatchers.IO) {
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
try {
tempFile.outputStream().use { output ->
inputStream.copyTo(output)
}
Timber.d("MOBI stream saved to temporary file: ${tempFile.absolutePath}")
} catch (e: Exception) {
Timber.e("Failed to write InputStream to temporary file.", e)
tempFile.delete()
return@withContext null
}
val parsedData = try {
parseMobiFile(tempFile.absolutePath)
} catch (e: UnsatisfiedLinkError) {
Timber.e("JNI call failed. Is the native library loaded correctly?", e)
null
} finally {
tempFile.delete()
}
if (parsedData?.rawHtmlContent == null) {
Timber.e("The native parser returned null or empty HTML content. Check JNI logs.")
return@withContext null
}
Timber.d("Received ${parsedData.resources.size} resources from JNI.")
val bookTitle = parsedData.title ?: originalBookNameHint
val bookAuthor = parsedData.author ?: "Unknown Author"
val bookIdentifier = bookTitle.asFileName() + "_" + UUID.randomUUID().toString().substring(0, 8)
val extractionDir = getBookExtractionDir(bookIdentifier)
extractionDir.mkdirs()
// This map is the key. It maps the 1-based sequential index of an image to its new path.
val sequentialImageMap = parsedData.resources
.filter { it.mediaType.startsWith("image/") }
.sortedBy { it.uid } // Sort by UID to ensure order is correct
.mapIndexed { index, resource -> (index + 1) to resource.path }
.toMap()
parsedData.resources.forEach { resource ->
try {
val file = File(extractionDir, resource.path)
file.parentFile?.mkdirs()
file.writeBytes(resource.data)
Timber.d("Wrote resource to disk -> Path: ${file.absolutePath}, Type: ${resource.mediaType}, Size: ${resource.data.size}")
} catch (e: Exception) {
Timber.e("Parser: FAILED to write resource to disk: ${resource.path}", e)
}
}
val cssFlowMap = parsedData.resources
.filter { it.mediaType == "text/css" && it.path.startsWith("flow_") }
.associate {
val index = it.path.removePrefix("flow_").removeSuffix(".css").toIntOrNull() ?: -1
"kindle:flow:${String.format("%04d", index)}?mime=text/css" to it.path
}
val processChapterHtml: (String) -> String = { html ->
val doc = Jsoup.parse(html)
doc.select("link[href]").forEach { link ->
val originalHref = link.attr("href")
cssFlowMap[originalHref]?.let { newPath ->
link.attr("href", newPath)
Timber.d("Rewrote CSS link from '$originalHref' to '$newPath'")
} ?: Timber.w("Could not find mapping for CSS link: $originalHref")
}
doc.select("img").forEach { img ->
val src = img.attr("src")
if (src.startsWith("kindle:embed:")) {
val embedIndexString = src.substringAfter("embed:").substringBefore("?")
val embedIndex = embedIndexString.toIntOrNull()
if (embedIndex != null) {
// **THE FIX**: Use the sequential map, not a UID map
sequentialImageMap[embedIndex]?.let { newPath ->
img.attr("src", newPath)
Timber.d("Rewrote image src from '$src' to '$newPath' using sequential map")
} ?: Timber.w("No resource found for sequential image index: $embedIndex")
}
} else if (img.hasAttr("recindex")) {
val recIndex = img.attr("recindex").toIntOrNull()
if (recIndex != null) {
sequentialImageMap[recIndex]?.let { newPath ->
img.attr("src", newPath)
img.removeAttr("recindex")
} ?: Timber.w("Kotlin: No matching image found for recindex: $recIndex")
}
}
}
doc.outerHtml()
}
// --- CHAPTER SPLITTING LOGIC ---
val rawHtmlBytes = parsedData.rawHtmlContent.toByteArray(Charsets.UTF_8)
val chapterHtmlParts = mutableListOf<Pair<String, String>>()
val sortedToc = parsedData.toc?.sorted()
if (sortedToc != null && sortedToc.isNotEmpty()) {
Timber.d("Splitting content using TOC (${sortedToc.size} entries).")
for (i in sortedToc.indices) {
val tocEntry = sortedToc[i]
val startByte = tocEntry.filePosition
val endByte = if (i + 1 < sortedToc.size) sortedToc[i + 1].filePosition else rawHtmlBytes.size
if (startByte >= endByte) continue
val chapterBytes = rawHtmlBytes.sliceArray(startByte until endByte)
val chapterHtml = String(chapterBytes, Charsets.UTF_8)
chapterHtmlParts.add(Pair(chapterHtml, tocEntry.title))
}
} else {
Timber.d("No TOC found. Falling back to splitting by <mbp:pagebreak/>.")
val parts = parsedData.rawHtmlContent.split("(?i)<mbp:pagebreak\\s*/>".toRegex())
parts.forEachIndexed { index, html ->
if (html.isNotBlank()) {
chapterHtmlParts.add(Pair(html, "Chapter ${index + 1}"))
}
}
}
Timber.d("Successfully split content into ${chapterHtmlParts.size} chapters.")
val epubChapters = chapterHtmlParts.mapIndexedNotNull { index, (chapterHtml, title) ->
try {
val rewrittenHtml = processChapterHtml(chapterHtml)
val doc = Jsoup.parse(rewrittenHtml)
val chapterFileName = "chapter_$index.html"
val chapterFile = File(extractionDir, chapterFileName)
chapterFile.writeText(rewrittenHtml)
EpubChapter(
chapterId = "mobi_chapter_$index",
title = title,
absPath = chapterFileName,
htmlFilePath = chapterFileName,
htmlContent = rewrittenHtml,
plainTextContent = doc.text()
)
} catch (e: Exception) {
Timber.e("Failed to process split chapter $index", e)
null
}
}
val images = parsedData.resources
.filter { it.mediaType.startsWith("image/") }
.map { EpubImage(absPath = it.path, image = it.data) }
val cssContent = parsedData.resources
.filter { it.mediaType == "text/css" }
.associate { it.path to String(it.data, Charsets.UTF_8) }
Timber.d("Extracted ${cssContent.size} CSS files.")
val coverImageBytes = if (parsedData.coverImageResourceUid != -1) {
parsedData.resources.find { it.uid == parsedData.coverImageResourceUid }?.data
} else {
null
}
val coverImage = coverImageBytes?.let { BitmapFactory.decodeByteArray(it, 0, it.size) }
if (coverImage == null) {
Timber.d("Kotlin: Cover image data not found for UID: ${parsedData.coverImageResourceUid}")
}
val finalBook = EpubBook(
fileName = bookTitle.asFileName(),
title = bookTitle,
author = bookAuthor,
language = "en",
coverImage = coverImage,
chapters = epubChapters,
chaptersForPagination = epubChapters,
images = images,
pageList = emptyList(),
extractionBasePath = extractionDir.absolutePath,
css = cssContent
)
Timber.d("Final EpubBook created. CSS map size: ${finalBook.css.size}, Image count: ${finalBook.images.size}")
return@withContext finalBook
}
}

View file

@ -0,0 +1,275 @@
// SingleFileImporter.kt
package com.aryan.reader.epub
import android.content.Context
import com.aryan.reader.FileType
import com.vladsch.flexmark.ext.autolink.AutolinkExtension
import com.vladsch.flexmark.ext.gfm.strikethrough.StrikethroughExtension
import com.vladsch.flexmark.ext.gfm.tasklist.TaskListExtension
import com.vladsch.flexmark.ext.tables.TablesExtension
import com.vladsch.flexmark.html.HtmlRenderer
import com.vladsch.flexmark.parser.Parser
import com.vladsch.flexmark.util.data.MutableDataSet
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
class SingleFileImporter(private val context: Context) {
suspend fun importSingleFile(
inputStream: InputStream,
type: FileType,
originalBookNameHint: String
): EpubBook {
return when (type) {
FileType.MD -> parseMarkdown(inputStream, originalBookNameHint)
FileType.TXT -> parsePlainText(inputStream, originalBookNameHint)
FileType.HTML -> parseHtml(inputStream, originalBookNameHint)
else -> parsePlainText(inputStream, originalBookNameHint) // Fallback
}
}
private suspend fun parseMarkdown(
inputStream: InputStream,
originalBookNameHint: String
): EpubBook = withContext(Dispatchers.IO) {
Timber.d("Parsing Markdown: $originalBookNameHint")
val title = originalBookNameHint.substringBeforeLast(".")
val markdownContent = inputStream.bufferedReader().use { it.readText() }
val options = MutableDataSet().apply {
set(Parser.EXTENSIONS, listOf(
TablesExtension.create(),
StrikethroughExtension.create(),
TaskListExtension.create(),
AutolinkExtension.create()
))
set(HtmlRenderer.GENERATE_HEADER_ID, true)
set(HtmlRenderer.RENDER_HEADER_ID, true)
}
val parser = Parser.builder(options).build()
val renderer = HtmlRenderer.builder(options).build()
val document = parser.parse(markdownContent)
val htmlBody = renderer.render(document)
val style = """
body { font-family: sans-serif; line-height: 1.6; padding: 1em; max-width: 800px; margin: 0 auto; }
table { border-collapse: collapse; width: 100%; margin: 1em 0; }
th, td { border: 1px solid currentColor; padding: 0.5em; text-align: left; }
blockquote { border-left: 4px solid currentColor; padding-left: 1em; margin-left: 0; opacity: 0.8; }
pre { overflow-x: auto; background: rgba(127,127,127,0.1); padding: 1em; border-radius: 4px; }
img { max-width: 100%; height: auto; }
""".trimIndent()
return@withContext createBookFromHtmlBody(title, htmlBody, style, originalBookNameHint, author = null)
}
private suspend fun parsePlainText(
inputStream: InputStream,
originalBookNameHint: String
): EpubBook = withContext(Dispatchers.IO) {
Timber.d("Parsing Plain Text with Virtual Chaptering: $originalBookNameHint")
val title = originalBookNameHint.substringBeforeLast(".")
val bookId = UUID.randomUUID().toString()
val extractionDir = File(context.cacheDir, "imported_txt_$bookId").apply {
if (!exists()) mkdirs()
}
val chapters = mutableListOf<EpubChapter>()
var chapterCounter = 1
val cssStyle = """
body { margin: 0; padding: 0; }
pre {
font-family: 'Courier New', Courier, monospace;
font-size: 1em;
line-height: 1.5;
white-space: pre-wrap;
overflow-wrap: anywhere;
word-break: break-word;
padding: 1em;
margin: 0;
}
""".trimIndent()
val currentChapterContent = StringBuilder()
val chapterTargetSize = 64 * 1024
fun flushChapter() {
if (currentChapterContent.isEmpty()) return
val fileName = "part_$chapterCounter.html"
val file = File(extractionDir, fileName)
val chapterTitle = "Part $chapterCounter"
val fullHtml = """
<!DOCTYPE html>
<html>
<head>
<title>$chapterTitle</title>
<style>$cssStyle</style>
</head>
<body><pre>${currentChapterContent}</pre></body>
</html>
""".trimIndent()
FileOutputStream(file).use { it.write(fullHtml.toByteArray()) }
val plainText = Jsoup.parse(fullHtml).text()
chapters.add(
EpubChapter(
chapterId = "${bookId}_$chapterCounter",
absPath = fileName,
title = chapterTitle,
htmlFilePath = fileName,
plainTextContent = plainText,
htmlContent = fullHtml,
depth = 0,
isInToc = true
)
)
currentChapterContent.clear()
chapterCounter++
}
val reader = inputStream.bufferedReader()
val buffer = CharArray(8192)
while (true) {
val readCount = reader.read(buffer)
if (readCount == -1) break
for (i in 0 until readCount) {
val c = buffer[i]
if ((c < ' ' && c != '\t' && c != '\n' && c != '\r')) {
continue
}
when (c) {
'<' -> currentChapterContent.append("&lt;")
'>' -> currentChapterContent.append("&gt;")
'&' -> currentChapterContent.append("&amp;")
else -> currentChapterContent.append(c)
}
}
if (currentChapterContent.length >= chapterTargetSize) {
flushChapter()
}
}
flushChapter()
if (chapters.isEmpty()) {
currentChapterContent.append("(Empty File)")
flushChapter()
}
Timber.d("Imported TXT split into ${chapters.size} chapters.")
return@withContext EpubBook(
fileName = originalBookNameHint,
title = title,
author = "Unknown",
language = "en",
coverImage = null,
chapters = chapters,
chaptersForPagination = chapters,
images = emptyList(),
pageList = emptyList(),
extractionBasePath = extractionDir.absolutePath,
css = emptyMap()
)
}
private suspend fun parseHtml(
inputStream: InputStream,
originalBookNameHint: String
): EpubBook = withContext(Dispatchers.IO) {
Timber.d("Importing HTML: $originalBookNameHint")
val content = inputStream.bufferedReader().use { it.readText() }
val doc = Jsoup.parse(content)
val title = doc.title().takeIf { it.isNotBlank() } ?: originalBookNameHint.substringBeforeLast(".")
val author = doc.select("meta[name=author]").attr("content").takeIf { it.isNotBlank() }
?: doc.select("meta[property=article:author]").attr("content").takeIf { it.isNotBlank() }
val finalHtml = doc.outerHtml()
createBookFromHtmlBody(title, null, null, originalBookNameHint, preGeneratedFullHtml = finalHtml, author = author)
}
private fun createBookFromHtmlBody(
title: String,
bodyContent: String?,
cssStyle: String?,
fileName: String,
preGeneratedFullHtml: String? = null,
author: String? = null
): EpubBook {
val fullHtml = preGeneratedFullHtml ?: """
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>${title.replace("\"", "&quot;")}</title>
<style>
${cssStyle ?: ""}
</style>
</head>
<body>
$bodyContent
</body>
</html>
""".trimIndent()
val plainText = Jsoup.parse(fullHtml).text()
val bookId = UUID.randomUUID().toString()
val extractionDir = File(context.cacheDir, "single_file_cache_$bookId").apply {
if (!exists()) mkdirs()
}
try {
File(extractionDir, "content.html").writeText(fullHtml)
} catch (e: Exception) {
Timber.e(e, "Failed to save generated HTML to disk")
}
val chapter = EpubChapter(
chapterId = bookId,
absPath = "content.html",
title = title,
htmlFilePath = "content.html",
plainTextContent = plainText,
htmlContent = fullHtml,
depth = 0,
isInToc = true
)
return EpubBook(
fileName = fileName,
title = title,
author = author ?: "",
language = "en",
coverImage = null,
chapters = listOf(chapter),
chaptersForPagination = listOf(chapter),
images = emptyList(),
pageList = emptyList(),
extractionBasePath = extractionDir.absolutePath,
css = emptyMap()
)
}
}

View file

@ -0,0 +1,965 @@
// ChapterWebView.kt
package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.graphics.Color
import android.graphics.Rect
import timber.log.Timber
import android.webkit.JavascriptInterface
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toast
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CopyAll
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.core.net.toUri
import com.aryan.reader.R
import com.aryan.reader.countWords
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.json.JSONObject
import java.io.BufferedReader
import java.io.InputStreamReader
private fun getFontCssInjection(): String {
return """
@font-face { font-family: 'Merriweather'; src: url('file:///android_asset/fonts/merriweather.ttf'); }
@font-face { font-family: 'Lato'; src: url('file:///android_asset/fonts/lato.ttf'); }
@font-face { font-family: 'Lora'; src: url('file:///android_asset/fonts/lora.ttf'); }
@font-face { font-family: 'Roboto Mono'; src: url('file:///android_asset/fonts/roboto_mono.ttf'); }
@font-face { font-family: 'Lexend'; src: url('file:///android_asset/fonts/lexend.ttf'); }
""".trimIndent()
}
private fun getJsToInject(context: Context): String {
return try {
context.assets.open("epub_reader.js").use { inputStream ->
BufferedReader(InputStreamReader(inputStream)).use { reader ->
reader.readText()
}
}
} catch (e: Exception) {
Timber.e(e, "Error reading epub_reader.js from assets")
"" // Return empty string on error
}
}
@Suppress("unused")
class AutoScrollJsBridge(
private val callback: () -> Unit
) {
@JavascriptInterface
fun onChapterEnd() {
Timber.d("Bridge: onChapterEnd called from JavaScript. Invoking callback.")
callback()
}
}
@Suppress("unused") // function used by JavaScript
class TtsJsBridge(
private val scope: CoroutineScope,
private val ttsStructuredTextHandler: suspend (String) -> Unit
) {
@JavascriptInterface
fun onStructuredTextExtracted(json: String) {
if (json.isNotBlank() && json != "[]") {
scope.launch {
ttsStructuredTextHandler(json)
}
} else {
scope.launch {
ttsStructuredTextHandler("[]")
}
}
}
}
@Suppress("unused")
class HighlightJsBridge(
private val onCreateCallback: (String, String, String) -> Unit, // Renamed to avoid recursion
private val onClickCallback: ((String, String, Int, Int, Int, Int) -> Unit)? = null // Renamed
) {
@JavascriptInterface
fun onHighlightCreated(cfi: String, text: String, colorId: String) {
onCreateCallback(cfi, text, colorId) // Calls the lambda property
}
@JavascriptInterface
fun onHighlightClicked(cfi: String, text: String, left: Int, top: Int, right: Int, bottom: Int) {
onClickCallback?.invoke(cfi, text, left, top, right, bottom)
}
}
@Suppress("unused")
class ContentBridge(
private val onChunkRequested: (index: Int) -> Unit
) {
@JavascriptInterface
fun requestChunk(index: Int) {
onChunkRequested(index)
}
}
@Suppress("unused")
class CfiJsBridge(
private val onCfiReady: (String) -> Unit,
private val onCfiForBookmarkReady: (String) -> Unit
) {
@JavascriptInterface
fun onCfiExtracted(jsonResponse: String) {
// This is called from JavaScript with the generated CFI and diagnostics
try {
val json = JSONObject(jsonResponse)
val cfi = json.optString("cfi", "/4")
val logArray = json.optJSONArray("log")
Timber.d("--- Start CFI Save Diagnostics ---")
Timber.d("Received CFI for saving: $cfi")
if (logArray != null) {
for (i in 0 until logArray.length()) {
Timber.d(logArray.getString(i))
}
} else {
Timber.d("No log array received. Raw response: $jsonResponse")
}
Timber.d("--- End CFI Save Diagnostics ---")
if (cfi.isNotBlank()) {
onCfiReady(cfi)
}
} catch (e: Exception) {
Timber.e(e, "Error parsing CFI JSON response: $jsonResponse")
// Still call back with a fallback CFI so the app doesn't hang
onCfiReady("/4")
}
}
@JavascriptInterface
fun onCfiForBookmarkExtracted(jsonResponse: String) {
// This is called from JavaScript with the generated CFI for a bookmark action
try {
val json = JSONObject(jsonResponse)
val cfi = json.optString("cfi")
val logArray = json.optJSONArray("log")
Timber.d("--- Start CFI Diagnostics (Bookmark) ---")
Timber.d("Received CFI for bookmark: $cfi")
if (logArray != null) {
for (i in 0 until logArray.length()) {
Timber.d(logArray.getString(i))
}
} else {
Timber.d("No log array received. Raw response: $jsonResponse")
}
Timber.d("--- End CFI Diagnostics (Bookmark) ---")
if (cfi != null) {
onCfiForBookmarkReady(cfi)
}
} catch (e: Exception) {
Timber.e(e, "Error parsing CFI JSON for bookmark: $jsonResponse")
}
}
}
@Suppress("unused")
class SnippetJsBridge(
private val onSnippetReady: (String, String) -> Unit
) {
@JavascriptInterface
fun onSnippetExtracted(cfi: String, snippet: String) {
Timber.d("SnippetJsBridge.onSnippetExtracted received. CFI: '$cfi', Snippet: '$snippet'")
onSnippetReady(cfi, snippet)
}
}
@Suppress("unused")
class ProgressJsBridge(
private val onTopChunkUpdated: (Int) -> Unit
) {
private var lastReportedChunk = -1
@JavascriptInterface
fun updateTopChunk(chunkIndex: Int) {
if (chunkIndex != lastReportedChunk) {
lastReportedChunk = chunkIndex
onTopChunkUpdated(chunkIndex)
}
}
}
private data class CustomMenuState(
val selectedText: String,
val selectionBounds: Rect,
val finishActionModeCallback: () -> Unit,
val cfi: String? = null,
val isExistingHighlight: Boolean = false
)
@Suppress("unused")
class AiJsBridge(
private val scope: CoroutineScope,
private val onContentReady: suspend (String) -> Unit
) {
@JavascriptInterface
fun onContentExtractedForSummarization(text: String) {
Timber.d("Content extracted for summarization, length: ${text.length}")
if (text.isNotBlank()) {
scope.launch {
onContentReady(text)
}
}
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun ChapterWebView(
key: Any,
initialHtmlContent: String,
baseUrl: String,
totalChunks: Int,
userHighlights: List<UserHighlight>,
onHighlightCreated: (String, String, String) -> Unit,
onHighlightDeleted: (String) -> Unit,
onChunkRequested: (Int) -> Unit,
chapterTitle: String,
isDarkTheme: Boolean,
initialScrollTarget: ChapterScrollPosition?,
initialPageScrollY: Int?,
initialCfi: String?,
initialChunkIndex: Int,
onTopChunkUpdated: (Int) -> Unit,
currentFontSize: Float,
currentLineHeight: Float,
onChapterInitiallyScrolled: () -> Unit,
onTap: () -> Unit,
onPotentialScroll: () -> Unit,
onOverScrollTop: (dragAmount: Float) -> Unit,
onOverScrollBottom: (dragAmount: Float) -> Unit,
onReleaseOverScrollTop: () -> Unit,
onReleaseOverScrollBottom: () -> Unit,
onScrollStateUpdate: (scrollY: Int, scrollHeight: Int, clientHeight: Int, activeFragmentId: String?) -> Unit,
onWebViewInstanceCreated: (WebView) -> Unit,
onCfiGenerated: (cfi: String) -> Unit,
onBookmarkCfiGenerated: (cfi: String) -> Unit,
onSnippetForBookmarkReady: (cfi: String, snippet: String) -> Unit,
ttsScope: CoroutineScope,
tocFragments: List<String>,
modifier: Modifier = Modifier,
initialFragmentId: String? = null,
onTtsTextReady: suspend (String) -> Unit,
isProUser: Boolean,
isOss: Boolean = false,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
onContentReadyForSummarization: suspend (String) -> Unit,
currentFontFamily: ReaderFont,
customFontPath: String? = null,
currentTextAlign: ReaderTextAlign,
onHighlightClicked: () -> Unit,
onAutoScrollChapterEnd: () -> Unit = {},
) {
Timber.d(
"RenderChapterViaWebView for '$chapterTitle', Key: $key, isDarkTheme: $isDarkTheme, initialScrollTarget: $initialScrollTarget"
)
var showExternalLinkDialog by remember { mutableStateOf<String?>(null) }
val context = LocalContext.current
val density = LocalDensity.current
var localWebViewRef by remember { mutableStateOf<WebView?>(null) }
var customMenuState by remember { mutableStateOf<CustomMenuState?>(null) }
val jsToInject = remember(context) { getJsToInject(context) }
LaunchedEffect(currentFontSize, currentLineHeight) {
localWebViewRef?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
null
)
}
val highlightsJson = remember(userHighlights) {
val jsonArray = org.json.JSONArray()
userHighlights.forEach { h ->
val obj = JSONObject()
obj.put("cfi", h.cfi)
obj.put("text", h.text)
obj.put("cssClass", h.color.cssClass)
jsonArray.put(obj)
}
jsonArray.toString()
}
if (showExternalLinkDialog != null) {
val urlToShow = showExternalLinkDialog!!
AlertDialog(
onDismissRequest = { showExternalLinkDialog = null },
title = { Text("External Link") },
text = { Text("You clicked on an external link:\n\n$urlToShow\n\nWhat would you like to do?") },
confirmButton = {
Row(horizontalArrangement = Arrangement.End) {
TextButton(onClick = {
val intent = Intent(Intent.ACTION_VIEW, urlToShow.toUri())
try {
context.startActivity(intent)
} catch (e: ActivityNotFoundException) {
Timber.e(e, "No activity found to handle intent for URL: $urlToShow")
Toast.makeText(
context,
"No browser found to open the link.",
Toast.LENGTH_LONG
).show()
}
showExternalLinkDialog = null
}) { Text("Open") }
TextButton(onClick = {
val clipboard =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Link", urlToShow)
clipboard.setPrimaryClip(clip)
showExternalLinkDialog = null
}) { Text("Copy") }
}
},
dismissButton = {
TextButton(onClick = { showExternalLinkDialog = null }) { Text("Cancel") }
}
)
}
Box(modifier = modifier.fillMaxSize()) {
key(
key,
isDarkTheme,
currentFontSize,
currentLineHeight,
currentFontFamily,
currentTextAlign
) {
AndroidView(
factory = { ctx ->
Timber.d(
"InteractiveWebView factory for $chapterTitle (Key: $key), isDarkTheme: $isDarkTheme, initialScroll: $initialScrollTarget"
)
val webView = InteractiveWebView(
context = ctx,
onSingleTap = onTap,
onPotentialScroll = onPotentialScroll,
onOverScrollTop = onOverScrollTop,
onOverScrollBottom = onOverScrollBottom,
onReleaseOverScrollTop = onReleaseOverScrollTop,
onReleaseOverScrollBottom = onReleaseOverScrollBottom,
onShowCustomSelectionMenu = { text, bounds, finishCallback ->
if (text.isNotBlank() && !bounds.isEmpty) {
customMenuState = CustomMenuState(
selectedText = text,
selectionBounds = Rect(bounds),
finishActionModeCallback = finishCallback,
isExistingHighlight = false
)
} else {
customMenuState = null
finishCallback()
}
},
onHideCustomSelectionMenu = {
if (customMenuState?.isExistingHighlight != true) {
customMenuState = null
}
}
).apply {
localWebViewRef = this
onWebViewInstanceCreated(this)
addJavascriptInterface(
PageInfoBridge(onScrollStateUpdate),
"PageInfoReporter"
)
addJavascriptInterface(
ProgressJsBridge(onTopChunkUpdated),
"ProgressReporter"
)
addJavascriptInterface(ContentBridge(onChunkRequested), "ContentBridge")
addJavascriptInterface(HighlightJsBridge(
onCreateCallback = onHighlightCreated,
onClickCallback = { cfi, text, left, top, right, bottom ->
onHighlightClicked()
val densityValue = density.density
val locationOnScreen = IntArray(2)
this.getLocationOnScreen(locationOnScreen)
val xOffset = locationOnScreen[0]
val yOffset = locationOnScreen[1]
val rect = Rect(
(left * densityValue).toInt() + xOffset,
(top * densityValue).toInt() + yOffset,
(right * densityValue).toInt() + xOffset,
(bottom * densityValue).toInt() + yOffset
)
customMenuState = CustomMenuState(
selectedText = text,
selectionBounds = rect,
finishActionModeCallback = {
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
},
cfi = cfi,
isExistingHighlight = true
)
}
), "HighlightBridge")
addJavascriptInterface(
AutoScrollJsBridge {
onAutoScrollChapterEnd()
},
"AutoScrollBridge"
)
webChromeClient = object : android.webkit.WebChromeClient() {
override fun onConsoleMessage(consoleMessage: android.webkit.ConsoleMessage?): Boolean {
consoleMessage?.let {
val message = it.message()
when {
message.startsWith("CFI_DIAGNOSIS:") -> {
Timber.d(
"JS -> ${message.substringAfter("CFI_DIAGNOSIS: ")}"
)
}
message.startsWith("ImageDiagnosis") -> {
Timber.d("JS -> $message")
}
message.startsWith("TTS_HIGHLIGHT_DIAGNOSIS:") -> {
Timber.d(
"JS -> ${message.substringAfter("TTS_HIGHLIGHT_DIAGNOSIS: ")}"
)
}
message.startsWith("HIGHLIGHT_DEBUG:") -> {
Timber.d(
"JS -> ${message.substringAfter("HIGHLIGHT_DEBUG: ")}"
)
}
message.startsWith("ReaderFontDiagnosis") -> {
Timber.d(
"JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}"
)
}
message.startsWith("AutoScrollDiagnosis") -> {
Timber.d(
"JS -> ${message.substringAfter("AutoScrollDiagnosis: ")}"
)
}
message.startsWith("FRAG_NAV_DEBUG") -> {
Timber.tag("FRAG_NAV_DEBUG").d("JS -> ${message.substringAfter("FRAG_NAV_DEBUG: ")}")
}
else -> {
Timber.d(
"[${it.sourceId()}:${it.lineNumber()}] ${it.message()}"
)
}
}
}
return true
}
}
addJavascriptInterface(
CfiJsBridge(
onCfiReady = { cfi -> onCfiGenerated(cfi) },
onCfiForBookmarkReady = { cfi -> onBookmarkCfiGenerated(cfi) }
), "CfiBridge")
addJavascriptInterface(SnippetJsBridge { cfi, snippet ->
onSnippetForBookmarkReady(
cfi,
snippet
)
}, "SnippetBridge")
addJavascriptInterface(TtsJsBridge(ttsScope, onTtsTextReady), "TtsBridge")
addJavascriptInterface(
AiJsBridge(ttsScope, onContentReadyForSummarization),
"AiBridge"
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
val url = request?.url?.toString()
if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) {
Timber.d("Intercepted external link: $url")
showExternalLinkDialog = url
return true
}
return false
}
override fun onLoadResource(view: WebView?, url: String?) {
super.onLoadResource(view, url)
if (url?.contains(".jpg", true) == true ||
url?.contains(".jpeg", true) == true ||
url?.contains(".png", true) == true ||
url?.contains(".gif", true) == true ||
url?.contains(".svg", true) == true ||
url?.contains("image", true) == true
) {
Timber.d(
"WebView is attempting to load resource: $url"
)
}
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Timber.d(
"onPageFinished. Injecting CSS and Font: ${currentFontFamily.fontFamilyName}"
)
view?.evaluateJavascript(jsToInject, null)
view?.evaluateJavascript(
"javascript:window.applyReaderTheme($isDarkTheme);",
null
)
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
Timber.tag("FRAG_NAV_DEBUG").d("onPageFinished: Re-injecting TOC_FRAGMENTS: $fragmentsJson")
view?.evaluateJavascript("javascript:window.TOC_FRAGMENTS = $fragmentsJson;", null)
view?.evaluateJavascript("javascript:setTimeout(window.auditTocFragments, 500);", null)
view?.evaluateJavascript("javascript:window.HighlightBridgeHelper.restoreHighlights('${escapeJsString(highlightsJson)}');", null)
val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) {
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
} else ""
val combinedCss = "$fontCss $customFontCss"
val injectFontJs =
"var style = document.createElement('style'); style.id='injectedFonts'; style.innerHTML = \"$combinedCss\"; document.head.appendChild(style);"
view?.evaluateJavascript("javascript:$injectFontJs") {
Timber.d("CSS Injection result: $it")
}
val fontNameForJs = if (customFontPath != null) {
"CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
""
} else {
currentFontFamily.fontFamilyName
}
view?.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}');",
null
)
view?.evaluateJavascript(
"javascript:window.checkImagesForDiagnosis();",
null
)
view?.evaluateJavascript(
"javascript:window.virtualization.init($initialChunkIndex, $totalChunks);",
null
)
@Suppress("VariableNeverRead") var scrollActionTaken = false
if (!initialCfi.isNullOrBlank()) {
val cfiJsCommand =
"javascript:window.scrollToCfi('$initialCfi');"
Timber.d(
"WebView onPageFinished: Executing initial scroll to CFI: $initialCfi"
)
view?.evaluateJavascript(cfiJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else if (!initialFragmentId.isNullOrBlank()) {
Timber.d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId")
view?.evaluateJavascript(
"javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }",
null
)
onChapterInitiallyScrolled()
scrollActionTaken = true
} else if (initialScrollTarget != null) {
val scrollJsCommand = when (initialScrollTarget) {
ChapterScrollPosition.END -> "javascript:window.scrollToChapterEnd();"
else -> "javascript:window.scrollToChapterStart();"
}
Timber.d(
"WebView onPageFinished: Executing initial scroll to target: $initialScrollTarget"
)
view?.evaluateJavascript(scrollJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else if (initialPageScrollY != null && initialPageScrollY > 0) {
val scrollJsCommand =
"javascript:window.scrollToSpecificY($initialPageScrollY);"
Timber.d(
"WebView onPageFinished: Executing initial scroll to Y: $initialPageScrollY"
)
view?.evaluateJavascript(scrollJsCommand) {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
} else {
Timber.d(
"WebView onPageFinished: No specific scroll, defaulting to start."
)
view?.evaluateJavascript("javascript:window.scrollToChapterStart();") {
onChapterInitiallyScrolled()
scrollActionTaken = true
}
}
view?.clearFocus()
view?.evaluateJavascript(
"javascript:if(window.getSelection) window.getSelection().removeAllRanges();",
null
)
}
}
settings.apply {
javaScriptEnabled = true
allowFileAccess = true
allowContentAccess = true
domStorageEnabled = true
layoutAlgorithm = WebSettings.LayoutAlgorithm.NORMAL
setNeedInitialFocus(false)
setSupportZoom(false)
builtInZoomControls = false
displayZoomControls = false
useWideViewPort = true
loadWithOverviewMode = true
}
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
this.setBackgroundColor(Color.TRANSPARENT)
Timber.d(
"WebView loading initial data with base URL: $baseUrl (Key: $key)"
)
loadDataWithBaseURL(baseUrl, initialHtmlContent, "text/html", "UTF-8", null)
}
webView
},
update = { webView ->
Timber.d(
"WebView update. Setting Font: ${currentFontFamily.fontFamilyName}"
)
localWebViewRef = webView
onWebViewInstanceCreated(webView)
val fontCss = getFontCssInjection().replace("\n", " ")
val customFontCss = if (customFontPath != null) {
"@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }"
} else ""
val combinedCss = "$fontCss $customFontCss"
val injectFontJs =
"var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";"
webView.evaluateJavascript("javascript:$injectFontJs", null)
val fontNameForJs = if (customFontPath != null) {
"CustomFont"
} else if (currentFontFamily == ReaderFont.ORIGINAL) {
""
} else {
currentFontFamily.fontFamilyName
}
val fragmentsJson = org.json.JSONArray(tocFragments).toString()
Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson")
webView.evaluateJavascript("javascript:window.setTocFragments($fragmentsJson);", null)
webView.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}');",
null
)
},
modifier = Modifier.fillMaxSize()
)
}
// Custom Selection Menu Popup
customMenuState?.let { state ->
val popupPositionProvider = remember(state.selectionBounds, density, state.isExistingHighlight) {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize
): IntOffset {
val topMargin = with(density) { 16.dp.toPx() }.toInt()
val bottomMargin = with(density) {
if (state.isExistingHighlight) 16.dp.toPx() else 60.dp.toPx()
}.toInt()
var x = state.selectionBounds.centerX() - popupContentSize.width / 2
var y = state.selectionBounds.top - popupContentSize.height - topMargin
if (y < with(density) { 24.dp.toPx() }.toInt()) {
y = state.selectionBounds.bottom + bottomMargin
}
if (x < 0) x = 0
if (x + popupContentSize.width > windowSize.width) {
x = windowSize.width - popupContentSize.width
}
if (y + popupContentSize.height > windowSize.height) {
y = windowSize.height - popupContentSize.height
}
if (y < 0) y = 0
return IntOffset(
x.coerceIn(0, windowSize.width - popupContentSize.width),
y.coerceIn(0, windowSize.height - popupContentSize.height)
)
}
}
}
Popup(
popupPositionProvider = popupPositionProvider,
onDismissRequest = {
state.finishActionModeCallback()
customMenuState = null
}
) {
Surface(
shape = RoundedCornerShape(12.dp),
shadowElevation = 6.dp,
color = MaterialTheme.colorScheme.surface,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Column(
modifier = Modifier.width(IntrinsicSize.Max)
) {
// 1. Color Row (Improved sizing and gaps)
Row(
modifier = Modifier
.padding(vertical = 12.dp, horizontal = 12.dp)
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center, // Centered colors
verticalAlignment = Alignment.CenterVertically
) {
HighlightColor.entries.forEach { colorEnum ->
Box(
modifier = Modifier
.padding(horizontal = 8.dp)
.size(24.dp)
.background(colorEnum.color, CircleShape)
.border(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape)
.clickable {
Timber.d("Kotlin: Color clicked. Existing? ${state.isExistingHighlight}")
if (state.isExistingHighlight && state.cfi != null) {
// UPDATE EXISTING HIGHLIGHT
Timber.d("Kotlin: Requesting UPDATE via JS for CFI: ${state.cfi}")
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.updateHighlightStyle('${state.cfi}', '${colorEnum.cssClass}', '${colorEnum.id}');",
null
)
} else {
// CREATE NEW HIGHLIGHT
Timber.d("Kotlin: Requesting CREATE via JS")
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.createUserHighlight('${colorEnum.cssClass}', '${colorEnum.id}');",
null
)
}
state.finishActionModeCallback()
localWebViewRef?.clearFocus()
customMenuState = null
}
)
}
}
// 2. Delete Option (Only for existing highlights)
if (state.isExistingHighlight && state.cfi != null) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
// LOGGING START
Timber.d("Kotlin: Popup Delete requested for clicked CFI: '${state.cfi}'")
// 1. IMPROVED LOOKUP: Check if the clicked CFI exists within any split CFI string
val highlightToDelete = userHighlights.find { h ->
h.cfi == state.cfi || h.cfi.split("|").contains(state.cfi)
}
if (highlightToDelete == null) {
Timber.e("Kotlin: ERROR - Lookup failed. CFI '${state.cfi}' not found in any highlight.")
} else {
Timber.d("Kotlin: SUCCESS - Found highlight object. Full CFI: '${highlightToDelete.cfi}', Color: ${highlightToDelete.color.id}")
val cssClassToDelete = highlightToDelete.color.cssClass
val allCfiParts = highlightToDelete.cfi.split("|")
allCfiParts.forEach { partCfi ->
Timber.d("Kotlin: Requesting JS removal for part: '$partCfi'")
localWebViewRef?.evaluateJavascript(
"javascript:window.HighlightBridgeHelper.removeHighlightByCfi('${escapeJsString(partCfi)}', '$cssClassToDelete');",
null
)
}
onHighlightDeleted(highlightToDelete.cfi)
}
state.finishActionModeCallback()
customMenuState = null
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = "Remove",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
Text(
text = "Remove",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error
)
}
}
HorizontalDivider()
// 2. Copy Option
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", state.selectedText)
clipboard.setPrimaryClip(clip)
state.finishActionModeCallback()
localWebViewRef?.clearFocus()
localWebViewRef?.evaluateJavascript("javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null)
customMenuState = null
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = Icons.Default.CopyAll,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
text = "Copy",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
// 3. Dictionary Option (Preserving Logic)
if (!isOss && state.selectedText.length <= 2000) {
HorizontalDivider()
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val textToDefine = state.selectedText
if (textToDefine.isNotBlank()) {
val wordCount = countWords(textToDefine)
if (isProUser || wordCount <= 1) {
onWordSelectedForAiDefinition(textToDefine)
} else {
onShowDictionaryUpsellDialog()
}
}
customMenuState = null
}
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
painter = painterResource(id = R.drawable.dictionary),
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Text(
text = "Dictionary",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,304 @@
// EpubReaderAi.kt
package com.aryan.reader.epubreader
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.res.painterResource
import com.aryan.reader.AiDefinitionPopup
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.R
import com.aryan.reader.SummarizationPopup
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
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,
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}")
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")
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
Timber.d("Summarization: Got response code $responseCode")
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) {
Timber.d("Summarization: Received line: $line")
try {
val jsonResponse = JSONObject(line!!)
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(
epubBook: EpubBook,
chapterIndex: Int,
characterLimit: Int,
summaryCacheManager: SummaryCacheManager,
paginator: IPaginator?,
onProgressUpdate: (String) -> Unit,
onResultUpdate: (String) -> 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,
onUpdate = { sb.append(it) },
onError = {
Timber.e("Failed to summarize Ch $i for recap: $it")
latch.complete(false)
},
onFinish = { latch.complete(true) }
)
val success = latch.await()
if (success && sb.isNotEmpty()) {
val summary = sb.toString()
summaryCacheManager.saveSummary(epubBook.title, i, 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,
onUpdate = { chunk -> onResultUpdate(chunk) },
onError = { error -> onError(error) },
onFinish = { onFinish() }
)
}
/**
* Container for all AI-related popups and dialogs (Summary, Recap, Definition, Upsells).
*/
@Composable
fun EpubReaderAiOverlays(
// Summarization State
showSummarizationPopup: Boolean,
summarizationResult: SummarizationResult?,
isSummarizationLoading: Boolean,
onDismissSummarization: () -> Unit,
showSummarizationUpsellDialog: Boolean,
onDismissSummarizationUpsell: () -> Unit,
// Recap State
showRecapPopup: Boolean,
recapResult: SummarizationResult?,
isRecapLoading: Boolean,
onDismissRecap: () -> Unit,
// Dictionary State
showAiDefinitionPopup: Boolean,
selectedTextForAi: String?,
aiDefinitionResult: AiDefinitionResult?,
isAiDefinitionLoading: Boolean,
onDismissAiDefinition: () -> Unit,
showDictionaryUpsellDialog: Boolean,
onDismissDictionaryUpsell: () -> Unit,
// Navigation
onNavigateToPro: () -> Unit,
isTtsSessionActive: Boolean
) {
if (showSummarizationPopup) {
SummarizationPopup(
title = "Chapter Summary",
result = summarizationResult,
isLoading = isSummarizationLoading,
onDismiss = onDismissSummarization,
isMainTtsActive = isTtsSessionActive
)
}
if (showRecapPopup) {
SummarizationPopup(
title = "Story Recap (Beta)",
result = recapResult,
isLoading = isRecapLoading,
onDismiss = onDismissRecap,
isMainTtsActive = isTtsSessionActive,
)
}
if (showSummarizationUpsellDialog) {
AlertDialog(
onDismissRequest = onDismissSummarizationUpsell,
icon = { Icon(painter = painterResource(id = R.drawable.summarize), contentDescription = null) },
title = { Text("Unlock Chapter Summarization") },
text = { Text("Get concise summaries of any chapter with Episteme Pro. Upgrade to start using this feature.") },
confirmButton = {
TextButton(onClick = {
onDismissSummarizationUpsell()
onNavigateToPro()
}) { Text("Learn More") }
},
dismissButton = {
TextButton(onClick = onDismissSummarizationUpsell) { Text("Not Now") }
}
)
}
if (showAiDefinitionPopup) {
AiDefinitionPopup(
word = selectedTextForAi,
result = aiDefinitionResult,
isLoading = isAiDefinitionLoading,
onDismiss = onDismissAiDefinition,
isMainTtsActive = isTtsSessionActive
)
}
if (showDictionaryUpsellDialog) {
AlertDialog(
onDismissRequest = onDismissDictionaryUpsell,
icon = { Icon(painter = painterResource(id = R.drawable.ai), contentDescription = null) },
title = { Text("Unlock Smart Dictionary") },
text = { Text("Defining entire phrases and paragraphs up to 2000 characters is a Pro feature. Upgrade to get instant definitions for any selected text.") },
confirmButton = {
TextButton(onClick = {
onDismissDictionaryUpsell()
onNavigateToPro()
}) { Text("Learn More") }
},
dismissButton = {
TextButton(onClick = onDismissDictionaryUpsell) { Text("Not Now") }
}
)
}
}

View file

@ -0,0 +1,268 @@
package com.aryan.reader.epubreader
import android.content.Context
import timber.log.Timber
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
import kotlin.math.min
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
data class Bookmark(
val cfi: String,
val chapterTitle: String,
val label: String? = null,
val snippet: String,
val pageInChapter: Int?,
val totalPagesInChapter: Int?,
val chapterIndex: Int
)
enum class HighlightColor(val id: String, val color: Color, val cssClass: String) {
YELLOW("yellow", Color(0xFFFBC02D), "user-highlight-yellow"),
GREEN("green", Color(0xFF388E3C), "user-highlight-green"),
BLUE("blue", Color(0xFF1976D2), "user-highlight-blue"),
RED("red", Color(0xFFD32F2F), "user-highlight-red")
}
data class UserHighlight(
val id: String = UUID.randomUUID().toString(),
val cfi: String,
val text: String,
val color: HighlightColor,
val chapterIndex: Int
)
fun escapeJsString(value: String): String {
return value
.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
.replace("\u2028", "\\u2028")
.replace("\u2029", "\\u2029")
}
// --- Persistence Helpers ---
fun loadBookmarks(context: Context, bookTitle: String, chapters: List<EpubChapter>, bookmarksJson: String?): Set<Bookmark> {
val stringSetToParse: Collection<String> = if (bookmarksJson != null) {
try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).map { jsonArray.getString(it) }
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from ViewModel")
emptyList()
}
} else {
val prefs = context.getSharedPreferences(BOOKMARK_PREFS_NAME, Context.MODE_PRIVATE)
val key = "bookmarks_cfi_${bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")}"
prefs.getStringSet(key, emptySet()) ?: emptySet()
}
return stringSetToParse.mapNotNull { jsonString ->
try {
val json = JSONObject(jsonString)
val chapterIndex = if (json.has("chapterIndex")) {
json.getInt("chapterIndex")
} else {
val chapterTitle = json.getString("chapterTitle")
chapters.indexOfFirst { it.title == chapterTitle }.coerceAtLeast(0)
}
Bookmark(
cfi = json.getString("cfi"),
chapterTitle = json.getString("chapterTitle"),
label = if (json.has("label")) json.getString("label") else null,
snippet = json.getString("snippet"),
pageInChapter = if (json.has("pageInChapter")) json.optInt("pageInChapter") else null,
totalPagesInChapter = if (json.has("totalPagesInChapter")) json.optInt("totalPagesInChapter") else null,
chapterIndex = chapterIndex
)
} catch (_: Exception) {
null
}
}.toSet()
}
fun saveHighlightsToPrefs(context: Context, bookTitle: String, highlights: List<UserHighlight>) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonArray = JSONArray()
highlights.forEach { h ->
val obj = JSONObject().apply {
put("id", h.id)
put("cfi", h.cfi)
put("text", h.text)
put("colorId", h.color.id)
put("chapterIndex", h.chapterIndex)
}
jsonArray.put(obj)
}
prefs.edit { putString(key, jsonArray.toString()) }
}
fun loadHighlightsFromPrefs(context: Context, bookTitle: String): List<UserHighlight> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val sanitizedTitle = bookTitle.replace("[^a-zA-Z0-9]".toRegex(), "")
val key = "highlights_data_$sanitizedTitle"
val jsonString = prefs.getString(key, "[]") ?: "[]"
val list = mutableListOf<UserHighlight>()
try {
val jsonArray = JSONArray(jsonString)
for (i in 0 until jsonArray.length()) {
val obj = jsonArray.getJSONObject(i)
val colorId = obj.getString("colorId")
val color = HighlightColor.entries.find { it.id == colorId } ?: HighlightColor.YELLOW
list.add(
UserHighlight(
id = obj.optString("id", UUID.randomUUID().toString()),
cfi = obj.getString("cfi"),
text = obj.getString("text"),
color = color,
chapterIndex = obj.getInt("chapterIndex")
)
)
}
} catch (e: Exception) {
Timber.e(e, "Error loading highlights")
}
return list
}
// --- Logic Helpers ---
fun processAndAddHighlight(
newCfi: String,
newText: String,
newColor: HighlightColor,
chapterIndex: Int,
currentList: MutableList<UserHighlight>
) {
val newParts = newCfi.split('|')
val newStartFull = newParts.first()
val newEndFull = newParts.last()
val newStartPath = newStartFull.split(':').first()
val newStartOffset = newStartFull.substringAfter(':', "0").toInt()
val newEndPath = newEndFull.split(':').first()
val newEndOffset = newEndFull.substringAfter(':', "0").toInt()
val iterator = currentList.iterator()
var finalStartPath = newStartPath
var finalStartOffset = newStartOffset
var finalEndPath = newEndPath
var finalEndOffset = newEndOffset
var finalText = newText
while (iterator.hasNext()) {
val existing = iterator.next()
if (existing.chapterIndex != chapterIndex || existing.color != newColor) continue
val exParts = existing.cfi.split('|')
val exStartFull = exParts.first()
val exEndFull = exParts.last()
val exStartPath = exStartFull.split(':').first()
val exStartOffset = exStartFull.substringAfter(':', "0").toInt()
val exEndPath = exEndFull.split(':').first()
val exEndOffset = exEndFull.substringAfter(':', "0").toInt()
fun comparePaths(p1: String, p2: String): Int {
val parts1 = p1.split('/').filter { it.isNotEmpty() }.mapNotNull { it.toIntOrNull() }
val parts2 = p2.split('/').filter { it.isNotEmpty() }.mapNotNull { it.toIntOrNull() }
val len = min(parts1.size, parts2.size)
for (i in 0 until len) {
if (parts1[i] != parts2[i]) return parts1[i] - parts2[i]
}
return parts1.size - parts2.size
}
val startCmp = comparePaths(exStartPath, newEndPath)
val endCmp = comparePaths(exEndPath, newStartPath)
val isDisjoint = (startCmp > 0) || (startCmp == 0 && exStartOffset > newEndOffset) ||
(endCmp < 0) || (endCmp == 0 && exEndOffset < newStartOffset)
if (!isDisjoint) {
iterator.remove()
val unionStartCmp = comparePaths(finalStartPath, exStartPath)
if (unionStartCmp > 0 || (unionStartCmp == 0 && finalStartOffset > exStartOffset)) {
finalStartPath = exStartPath
finalStartOffset = exStartOffset
}
val unionEndCmp = comparePaths(finalEndPath, exEndPath)
if (unionEndCmp < 0 || (unionEndCmp == 0 && finalEndOffset < exEndOffset)) {
finalEndPath = exEndPath
finalEndOffset = exEndOffset
}
if (existing.text.length > finalText.length) finalText = existing.text
}
}
currentList.add(UserHighlight(
cfi = "$finalStartPath:$finalStartOffset|$finalEndPath:$finalEndOffset",
text = finalText,
color = newColor,
chapterIndex = chapterIndex
))
}
// --- UI Components ---
@Composable
fun BookmarkButton(
isBookmarked: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
Box(
modifier = modifier
.width(48.dp)
.height(48.dp)
.clip(RectangleShape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick
),
contentAlignment = Alignment.TopCenter
) {
AnimatedVisibility(
visible = isBookmarked,
enter = fadeIn(),
exit = fadeOut()
) {
Icon(
painter = painterResource(id = R.drawable.bookmark),
contentDescription = "Bookmark",
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}

View file

@ -0,0 +1,96 @@
package com.aryan.reader.epubreader
import timber.log.Timber
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.LocatorConverter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import java.io.File
data class ChapterLoadingResult(
val head: String,
val chunks: List<String>,
val startChunkIndex: Int,
val isSuccess: Boolean,
val errorMessage: String? = null
)
/**
* loads the chapter HTML, splits it into chunks, and calculates
* the initial chunk to display based on navigation state (CFI, overrides, etc).
*/
suspend fun loadChapterContent(
epubBook: EpubBook,
chapterIndex: Int,
chunkTargetOverride: Int?,
isInitialCfiLoad: Boolean,
cfiToLoad: String?,
locatorConverter: LocatorConverter
): ChapterLoadingResult = withContext(Dispatchers.IO) {
val chapter = epubBook.chapters.getOrNull(chapterIndex)
if (chapter == null) {
return@withContext ChapterLoadingResult("", emptyList(), 0, false, "Chapter index out of bounds")
}
try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
val htmlFile = File(fullPath)
val (headContent, chunks) = if (htmlFile.exists()) {
val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html()
val bodyChildren = doc.body().children().toList()
// Split into chunks of 20 elements
val chunkedList = bodyChildren.chunked(20).map { chunkOfElements ->
chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
}
// Fallback for empty chapters
if (chunkedList.isEmpty()) {
head to listOf("<body><p>This chapter is empty.</p></body>")
} else {
head to chunkedList
}
} else {
"" to listOf("<h1>Chapter not found</h1>")
}
var targetChunk = 0
if (chunkTargetOverride != null) {
Timber.d("Applying chunk target override: $chunkTargetOverride")
targetChunk = chunkTargetOverride
}
else if (isInitialCfiLoad && cfiToLoad != null) {
Timber.d("Calculating target chunk for initial CFI: $cfiToLoad")
val locator = locatorConverter.getLocatorFromCfi(epubBook, chapterIndex, cfiToLoad)
val calculatedChunk = locator?.let { it.blockIndex / 20 }
if (calculatedChunk != null) {
targetChunk = calculatedChunk
} else {
Timber.w("Could not determine target chunk for CFI. Loading all (fallback to last).")
targetChunk = if (chunks.isNotEmpty()) chunks.size - 1 else 0
}
}
targetChunk = targetChunk.coerceIn(0, maxOf(0, chunks.size - 1))
ChapterLoadingResult(
head = headContent,
chunks = chunks,
startChunkIndex = targetChunk,
isSuccess = true
)
} catch (e: Exception) {
Timber.e(e, "Failed to parse chapter")
ChapterLoadingResult(
head = "",
chunks = listOf("<h1>Error loading chapter</h1><p>${e.message}</p>"),
startChunkIndex = 0,
isSuccess = false,
errorMessage = e.message
)
}
}

View file

@ -0,0 +1,781 @@
// EpubReaderControls.kt
package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.Canvas
import android.os.Build
import timber.log.Timber
import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
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.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
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.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ChevronLeft
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
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.sp
import androidx.core.graphics.createBitmap
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.BuildConfig
import com.aryan.reader.R
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlin.math.roundToInt
@Composable
fun EpubReaderTopBar(
isVisible: Boolean,
searchState: SearchState,
bookTitle: String,
currentRenderMode: RenderMode,
isBookmarked: Boolean,
isTtsActive: Boolean,
tapToNavigateEnabled: Boolean,
volumeScrollEnabled: Boolean,
onNavigateBack: () -> Unit,
onCloseSearch: () -> Unit,
onChangeRenderMode: (RenderMode) -> Unit,
onToggleBookmark: () -> Unit,
onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit,
onStartAutoScroll: () -> Unit,
searchFocusRequester: androidx.compose.ui.focus.FocusRequester,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
modifier = modifier
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.height(55.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (searchState.isSearchActive) {
SearchTopBar(
searchState = searchState,
focusRequester = searchFocusRequester,
onCloseSearch = onCloseSearch
)
} else {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Spacer(Modifier.width(8.dp))
Text(
text = bookTitle.take(40) + if (bookTitle.length > 40) "..." else "",
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Box {
var showMoreMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showMoreMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More Options")
}
DropdownMenu(
expanded = showMoreMenu,
onDismissRequest = { showMoreMenu = false }
) {
DropdownMenuItem(
text = { Text("Reading Mode: Vertical") },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
trailingIcon = { if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(Icons.Default.Check, contentDescription = "Selected") }
)
DropdownMenuItem(
text = { Text("Reading Mode: Paginated") },
enabled = !isTtsActive,
onClick = {
showMoreMenu = false
onChangeRenderMode(RenderMode.PAGINATED)
},
trailingIcon = { if (currentRenderMode == RenderMode.PAGINATED) Icon(Icons.Default.Check, contentDescription = "Selected") }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text(if (isBookmarked) "Remove bookmark" else "Bookmark this page") },
onClick = {
showMoreMenu = false
onToggleBookmark()
}
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Tap to Turn Pages") },
enabled = currentRenderMode == RenderMode.PAGINATED,
onClick = {
onToggleTapToNavigate(!tapToNavigateEnabled)
showMoreMenu = false
},
trailingIcon = { if (tapToNavigateEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Volume Button Scrolling") },
enabled = currentRenderMode == RenderMode.VERTICAL_SCROLL,
onClick = {
onToggleVolumeScroll(!volumeScrollEnabled)
showMoreMenu = false
},
trailingIcon = { if (volumeScrollEnabled) Icon(Icons.Default.Check, contentDescription = "Enabled") }
)
HorizontalDivider()
DropdownMenuItem(
text = { Text("Auto Scroll") },
enabled = !isTtsActive && currentRenderMode == RenderMode.VERTICAL_SCROLL,
onClick = {
showMoreMenu = false
onStartAutoScroll()
}
)
}
}
}
}
}
}
}
@androidx.annotation.OptIn(UnstableApi::class)
@Composable
fun EpubReaderBottomBar(
isVisible: Boolean,
currentRenderMode: RenderMode,
isTtsSessionActive: Boolean,
ttsState: TtsState,
isProUser: Boolean,
onOpenSlider: () -> Unit,
onOpenDrawer: () -> Unit,
onToggleFormat: () -> Unit,
onToggleSearch: () -> Unit,
onSummarize: () -> Unit,
onRecap: () -> Unit,
onToggleTts: () -> Unit,
onPlayPauseTts: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier
) {
Surface(
modifier = Modifier.fillMaxWidth().height(45.dp),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceAround
) {
IconButton(
onClick = onOpenSlider,
enabled = currentRenderMode != RenderMode.VERTICAL_SCROLL
) {
Icon(painter = painterResource(id = R.drawable.slider), contentDescription = "Navigate with slider")
}
IconButton(onClick = onOpenDrawer) {
Icon(imageVector = Icons.Default.Menu, contentDescription = "Chapters Menu")
}
IconButton(onClick = onToggleFormat) {
Icon(painter = painterResource(id = R.drawable.format_size), contentDescription = "Text Formatting")
}
IconButton(onClick = onToggleSearch) {
Icon(imageVector = Icons.Default.Search, contentDescription = "Search")
}
@Suppress("KotlinConstantConditions")
if (BuildConfig.FLAVOR != "oss") {
Box {
var showAiFeaturesMenu by remember { mutableStateOf(false) }
IconButton(onClick = { showAiFeaturesMenu = true }) {
Icon(painter = painterResource(id = R.drawable.ai), contentDescription = "AI Features")
}
DropdownMenu(
expanded = showAiFeaturesMenu,
onDismissRequest = { showAiFeaturesMenu = false }
) {
DropdownMenuItem(
text = { Text("Chapter Summarization") },
onClick = {
showAiFeaturesMenu = false
onSummarize()
}
)
if (BuildConfig.DEBUG && isProUser) {
HorizontalDivider()
DropdownMenuItem(
text = { Text("Recap (Beta)") },
onClick = {
showAiFeaturesMenu = false
onRecap()
}
)
}
}
}
}
Box {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onToggleTts) {
Icon(
painter = if (isTtsSessionActive) painterResource(id = R.drawable.close) else painterResource(id = R.drawable.text_to_speech),
contentDescription = if (isTtsSessionActive) "Stop TTS" else "Start TTS"
)
}
if (isTtsSessionActive) {
IconButton(
onClick = onPlayPauseTts,
enabled = !ttsState.isLoading
) {
Icon(
painter = painterResource(id = if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
contentDescription = if (ttsState.isPlaying) "Pause TTS" else "Resume TTS"
)
}
}
}
}
}
}
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@SuppressLint("UnusedBoxWithConstraintsScope")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EpubReaderPageSlider(
isVisible: Boolean,
currentRenderMode: RenderMode,
totalPages: Int,
sliderCurrentPage: Float,
sliderStartPage: Int,
startPageThumbnail: Bitmap?,
paginator: IPaginator?,
chapters: List<EpubChapter>,
onClose: () -> Unit,
onScrub: (Float) -> Unit,
onJumpToPage: (Int) -> Unit
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { fullHeight -> fullHeight } + fadeIn(),
exit = slideOutVertically { fullHeight -> fullHeight } + fadeOut()
) {
Box(modifier = Modifier.fillMaxSize()) {
// Dismiss area
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onClose() }
)
// Fast scrub overlay
// Note: In the refactor, we rely on the parent or this logic to determine "isFastScrubbing".
// Since `isFastScrubbing` was state in the parent, we'll implement a local check or just show it if `isVisible`.
// Ideally, the parent handles the "Scrubbing Animation" separately, but let's bundle it here for simplicity.
// For now, we only show the static overlay logic.
// If we want the big center indicator, we can render it based on interaction state here.
// Top back button
IconButton(
onClick = onClose,
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Top + WindowInsetsSides.Start))
.padding(8.dp)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Exit slider navigation"
)
}
// Bottom controls
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + 16.dp)
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
BoxWithConstraints(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
Slider(
value = sliderCurrentPage,
onValueChange = onScrub,
valueRange = 1f..(totalPages.toFloat().coerceAtLeast(1f)),
steps = if (totalPages > 2) totalPages - 2 else 0,
modifier = Modifier.fillMaxWidth(),
thumb = {
Surface(
modifier = Modifier.size(20.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.primary,
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {}
},
track = { sliderState ->
val trackHeight = 2.dp
val trackShape = RoundedCornerShape(trackHeight)
val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
val fraction = if (range == 0f) 0f else {
((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
}
Box(
modifier = Modifier
.fillMaxWidth()
.height(trackHeight)
.background(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.3f),
shape = trackShape
)
) {
Box(
modifier = Modifier
.fillMaxWidth(fraction)
.fillMaxHeight()
.background(
color = MaterialTheme.colorScheme.primary,
shape = trackShape
)
)
}
}
)
// Thumbnail Indicator
val startPageOffsetFraction = if (totalPages > 1) {
(sliderStartPage - 1).toFloat() / (totalPages - 1)
} else {
0f
}
val thumbWidth = 20.dp
val trackWidth = maxWidth - thumbWidth
val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
val thumbnailModifier = Modifier
.graphicsLayer { clip = false }
.align(Alignment.TopStart)
.offset(
x = startPagePixelPosition - (45.dp / 2),
y = (-72).dp
)
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
startPageThumbnail?.let { thumbnail ->
ThumbnailWithIndicator(
modifier = thumbnailModifier,
onClick = { onJumpToPage(sliderStartPage) }
) {
Image(
bitmap = thumbnail.asImageBitmap(),
contentDescription = "Start page thumbnail",
contentScale = ContentScale.FillBounds,
modifier = Modifier.fillMaxSize()
)
}
}
} else {
val startPageChapterIndex = remember(sliderStartPage, paginator) {
(paginator as? BookPaginator)?.findChapterIndexForPage(sliderStartPage - 1)
}
val startPageChapterTitle = remember(startPageChapterIndex) {
startPageChapterIndex?.let { chapters.getOrNull(it)?.title }
}
ThumbnailWithIndicator(
modifier = thumbnailModifier,
onClick = { onJumpToPage(sliderStartPage) }
) {
PaginatedThumbnailContent(
pageNumber = sliderStartPage,
chapterTitle = startPageChapterTitle
)
}
}
}
Text(
text = "${sliderCurrentPage.roundToInt()} / $totalPages",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontSize = 18.sp
)
}
}
}
}
}
// --- Helpers moved from Screen ---
@Composable
fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
shape = RoundedCornerShape(16.dp)
)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Icon(
painter = painterResource(id = R.drawable.slider),
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(12.dp))
Text(
text = "Page $currentPage of $totalPages",
style = MaterialTheme.typography.headlineSmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
}
@Composable
internal fun ThumbnailWithIndicator(modifier: Modifier = Modifier, onClick: () -> Unit, content: @Composable () -> Unit) {
val borderColor = MaterialTheme.colorScheme.primary
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier
.width(45.dp)
.height(64.dp)
.clickable(onClick = onClick),
shape = RoundedCornerShape(4.dp),
border = BorderStroke(2.dp, borderColor)
) {
content()
}
Box(
modifier = Modifier
.offset(y = (-4).dp)
.size(8.dp)
.rotate(45f)
.background(borderColor)
)
}
}
@Composable
private fun PaginatedThumbnailContent(pageNumber: Int, chapterTitle: String?) {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant
) {
Column(
modifier = Modifier.padding(4.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
if (chapterTitle != null) {
Text(
text = chapterTitle,
style = MaterialTheme.typography.labelSmall,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
lineHeight = 10.sp
)
Spacer(modifier = Modifier.height(4.dp))
}
Text(
text = "$pageNumber",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold
)
}
}
}
suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? {
return withContext(Dispatchers.Main) {
if (webView.width <= 0 || webView.height <= 0) return@withContext null
try {
val thumbnailWidth = 180
val thumbnailHeight = 256
val bitmap = createBitmap(thumbnailWidth, thumbnailHeight)
val canvas = Canvas(bitmap)
val scale = thumbnailWidth.toFloat() / webView.width.toFloat()
canvas.scale(scale, scale)
canvas.translate(-webView.scrollX.toFloat(), -webView.scrollY.toFloat())
webView.draw(canvas)
bitmap
} catch (e: Exception) {
Timber.e(e, "Failed to capture webview content")
null
}
}
}
@Composable
fun AutoScrollControls(
isPlaying: Boolean,
onPlayPauseToggle: () -> Unit,
speed: Float,
onSpeedChange: (Float) -> Unit,
onClose: () -> Unit,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 6.dp,
shadowElevation = 6.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
modifier = modifier.animateContentSize()
) {
AnimatedContent(
targetState = isCollapsed,
transitionSpec = {
fadeIn(tween(200)) togetherWith fadeOut(tween(200))
},
label = "AutoScrollUnified"
) { collapsed ->
Row(
modifier = Modifier.padding(6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
if (collapsed) {
IconButton(
onClick = { onCollapseChange(false) },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.ChevronLeft,
contentDescription = "Expand",
tint = MaterialTheme.colorScheme.onSurface
)
}
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp)
)
}
} else {
IconButton(
onClick = onClose,
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close",
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(20.dp)
)
}
Box(
modifier = Modifier
.width(1.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
IconButton(
onClick = { onSpeedChange((speed - 0.1f).coerceAtLeast(0.1f)) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Remove, "Slower", modifier = Modifier.size(18.dp))
}
Text(
text = "%.1fx".format(speed),
style = MaterialTheme.typography.labelLarge.copy(fontFeatureSettings = "tnum"),
modifier = Modifier.widthIn(min = 40.dp),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface
)
IconButton(
onClick = { onSpeedChange((speed + 0.1f).coerceAtMost(10f)) },
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Default.Add, "Faster", modifier = Modifier.size(18.dp))
}
}
Box(
modifier = Modifier
.width(1.dp)
.height(24.dp)
.background(MaterialTheme.colorScheme.outlineVariant)
)
FilledIconButton(
onClick = onPlayPauseToggle,
modifier = Modifier.size(40.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
) {
Icon(
imageVector = if (isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (isPlaying) "Pause" else "Play",
modifier = Modifier.size(24.dp)
)
}
IconButton(
onClick = { onCollapseChange(true) },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = Icons.Default.ChevronRight,
contentDescription = "Collapse",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}

View file

@ -0,0 +1,731 @@
// EpubReaderDrawer.kt
package com.aryan.reader.epubreader
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.gestures.rememberDraggableState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastSumBy
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.EpubTocEntry
import kotlinx.coroutines.launch
import timber.log.Timber
@Composable
fun VerticalScrollbar(
listState: LazyListState,
modifier: Modifier = Modifier
) {
val interactionSource = remember { MutableInteractionSource() }
val isDragged by interactionSource.collectIsDraggedAsState()
val scrollbarState by remember {
derivedStateOf {
val layoutInfo = listState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
val visibleItemsInfo = layoutInfo.visibleItemsInfo
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
if (totalItems == 0 || visibleItemsInfo.isEmpty() || viewportHeight <= 0f) {
return@derivedStateOf null
}
val averageItemHeight = visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat()
val estimatedContentHeight = (averageItemHeight * totalItems).coerceAtLeast(viewportHeight)
val viewportRatio = viewportHeight / estimatedContentHeight
if (viewportRatio >= 1f) return@derivedStateOf null
val thumbHeight = (viewportHeight * viewportRatio).coerceIn(80f, viewportHeight / 2)
val firstItemIndex = listState.firstVisibleItemIndex
val firstItemOffset = listState.firstVisibleItemScrollOffset
val currentScrollPixels = (firstItemIndex * averageItemHeight) + firstItemOffset
val maxScrollPixels = estimatedContentHeight - viewportHeight
val scrollProgress = (currentScrollPixels / maxScrollPixels).coerceIn(0f, 1f)
val trackHeight = viewportHeight - thumbHeight
val thumbOffset = trackHeight * scrollProgress
ScrollbarCalculations(
thumbHeight = thumbHeight,
thumbOffset = thumbOffset,
contentHeight = estimatedContentHeight,
viewportHeight = viewportHeight
)
}
}
val targetAlpha = if (listState.isScrollInProgress || isDragged) 1f else 0f
val alpha by animateFloatAsState(
targetValue = targetAlpha,
animationSpec = tween(durationMillis = 200),
label = "ScrollbarAlpha"
)
if (scrollbarState != null) {
val state = scrollbarState!!
val draggableState = rememberDraggableState { delta ->
val trackHeight = state.viewportHeight - state.thumbHeight
if (trackHeight > 0) {
val scrollRatio = delta / trackHeight
val totalScrollableDistance = state.contentHeight - state.viewportHeight
val scrollDelta = scrollRatio * totalScrollableDistance
listState.dispatchRawDelta(scrollDelta)
}
}
Box(
modifier = modifier
.width(30.dp)
.fillMaxHeight()
.draggable(
state = draggableState,
orientation = Orientation.Vertical,
interactionSource = interactionSource
)
) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.graphicsLayer {
translationY = state.thumbOffset
}
.padding(end = 4.dp)
.width(6.dp)
.height(with(androidx.compose.ui.platform.LocalDensity.current) { state.thumbHeight.toDp() })
.alpha(alpha)
.background(
color = if (isDragged) MaterialTheme.colorScheme.primary.copy(alpha = 0.8f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
shape = RoundedCornerShape(100)
)
)
}
}
}
private data class ScrollbarCalculations(
val thumbHeight: Float,
val thumbOffset: Float,
val contentHeight: Float,
val viewportHeight: Float
)
@Composable
fun EpubReaderDrawerSheet(
chapters: List<EpubChapter>,
tableOfContents: List<EpubTocEntry>,
activeFragmentId: String?,
bookmarks: Set<Bookmark>,
userHighlights: List<UserHighlight>,
currentChapterIndex: Int,
currentChapterInPaginatedMode: Int?,
renderMode: RenderMode,
onNavigateToChapter: (Int) -> Unit,
onNavigateToTocEntry: (EpubTocEntry) -> Unit,
onNavigateToBookmark: (Bookmark) -> Unit,
onNavigateToHighlight: (UserHighlight) -> Unit,
onDeleteBookmark: (Bookmark) -> Unit,
onRenameBookmark: (Bookmark, String) -> Unit,
onDeleteHighlight: (UserHighlight) -> Unit
) {
ModalDrawerSheet(
modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)
) {
val drawerPagerState = rememberPagerState(pageCount = { 3 })
val drawerScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
Tab(
selected = drawerPagerState.currentPage == 0,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(0) } },
text = { Text("Chapters") }
)
Tab(
selected = drawerPagerState.currentPage == 1,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(1) } },
text = { Text("Bookmarks") }
)
Tab(
selected = drawerPagerState.currentPage == 2,
onClick = { drawerScope.launch { drawerPagerState.animateScrollToPage(2) } },
text = { Text("Highlights") }
)
}
HorizontalPager(
state = drawerPagerState,
modifier = Modifier
.fillMaxWidth()
.weight(1f)
) { page ->
when (page) {
0 -> ChaptersList(
chapters = chapters,
tocEntries = tableOfContents,
currentChapterIndex = currentChapterIndex,
currentChapterInPaginatedMode = currentChapterInPaginatedMode,
renderMode = renderMode,
onNavigateToTocEntry = onNavigateToTocEntry,
onNavigateToChapter = onNavigateToChapter,
activeFragmentId = activeFragmentId
)
1 -> BookmarksList(
bookmarks = bookmarks,
onNavigateToBookmark = onNavigateToBookmark,
onRenameBookmark = onRenameBookmark,
onDeleteBookmark = onDeleteBookmark
)
2 -> HighlightsList(
userHighlights = userHighlights,
chapters = chapters,
onNavigateToHighlight = onNavigateToHighlight,
onDeleteHighlight = onDeleteHighlight
)
}
}
}
}
}
@Composable
private fun ChaptersList(
chapters: List<EpubChapter>,
tocEntries: List<EpubTocEntry>,
currentChapterIndex: Int,
currentChapterInPaginatedMode: Int?,
renderMode: RenderMode,
activeFragmentId: String?,
onNavigateToTocEntry: (EpubTocEntry) -> Unit,
onNavigateToChapter: (Int) -> Unit
) {
val listState = rememberLazyListState()
val effectiveToc = remember(tocEntries, chapters) {
tocEntries.ifEmpty {
chapters.map { EpubTocEntry(it.title, it.absPath, null, it.depth) }
}
}
val currentChapterPath = remember(chapters, currentChapterIndex, currentChapterInPaginatedMode, renderMode) {
val idx = when (renderMode) {
RenderMode.PAGINATED -> currentChapterInPaginatedMode ?: -1
RenderMode.VERTICAL_SCROLL -> currentChapterIndex
}
chapters.getOrNull(idx)?.absPath
}
val firstEntryForCurrentChapter = remember(effectiveToc, currentChapterPath) {
val entry = effectiveToc.firstOrNull { it.absolutePath == currentChapterPath }
Timber.tag("FRAG_NAV_DEBUG").d("Computed First Entry for Chapter: '${entry?.label}' (Path: $currentChapterPath)")
entry
}
val allParentIndices = remember(effectiveToc) {
effectiveToc.indices.filter { i ->
val next = effectiveToc.getOrNull(i + 1)
next != null && next.depth > effectiveToc[i].depth
}.toSet()
}
var expandedEntryIndices by rememberSaveable(effectiveToc) {
mutableStateOf(allParentIndices)
}
val visibleItemInfo = remember(effectiveToc, expandedEntryIndices) {
val result = mutableListOf<Pair<Int, EpubTocEntry>>()
val visibilityStack = BooleanArray(50) { false }
visibilityStack[0] = true
for (i in effectiveToc.indices) {
val entry = effectiveToc[i]
val depth = entry.depth.coerceIn(0, 49)
if (visibilityStack[depth]) {
result.add(i to entry)
val isExpanded = expandedEntryIndices.contains(i)
if (depth + 1 < visibilityStack.size) {
visibilityStack[depth + 1] = isExpanded
}
} else {
if (depth + 1 < visibilityStack.size) {
visibilityStack[depth + 1] = false
}
}
}
result
}
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxHeight().padding(end = 12.dp)
) {
items(
items = visibleItemInfo,
key = { (index, entry) -> "${entry.absolutePath}_${entry.fragmentId}_$index" }
) { (originalIndex, entry) ->
val nextItem = effectiveToc.getOrNull(originalIndex + 1)
val hasChildren = nextItem != null && nextItem.depth > entry.depth
val isExpanded = expandedEntryIndices.contains(originalIndex)
// HIGHLIGHT LOGIC FIXED
val isCurrentPath = currentChapterPath == entry.absolutePath
val matchesFragment = entry.fragmentId == activeFragmentId
// Fallback logic
val isFallback = activeFragmentId == null && entry == firstEntryForCurrentChapter
val isHighlighting = isCurrentPath && (matchesFragment || isFallback)
if (isCurrentPath) {
Timber.tag("FRAG_NAV_DEBUG").d("Row: '${entry.label}' | isPathMatch: $isCurrentPath | isFragMatch: $matchesFragment | isFallback: $isFallback")
}
if (isCurrentPath) {
Timber.tag("FRAG_NAV_DEBUG").d("Entry: '${entry.label}' | ID: ${entry.fragmentId} | Active: $activeFragmentId | Highlight: $isHighlighting")
}
TocTreeItem(
label = entry.label,
depth = entry.depth,
isExpanded = isExpanded,
hasChildren = hasChildren,
isCurrent = isHighlighting,
onToggleExpand = {
expandedEntryIndices = if (isExpanded) {
expandedEntryIndices - originalIndex
} else {
expandedEntryIndices + originalIndex
}
},
onClick = {
if (tocEntries.isEmpty()) {
onNavigateToChapter(originalIndex)
} else {
onNavigateToTocEntry(entry)
}
}
)
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
@Composable
private fun TocTreeItem(
label: String,
depth: Int,
isExpanded: Boolean,
hasChildren: Boolean,
isCurrent: Boolean,
onToggleExpand: () -> Unit,
onClick: () -> Unit
) {
val backgroundColor by animateColorAsState(
targetValue = if (isCurrent) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f) else Color.Transparent,
label = "TocItemBackground"
)
val contentColor = if (isCurrent) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.background(backgroundColor)
.clickable(onClick = onClick)
.padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width((16 * depth).dp))
Box(
modifier = Modifier
.size(40.dp)
.clickable(
enabled = hasChildren,
onClick = onToggleExpand
),
contentAlignment = Alignment.Center
) {
if (hasChildren) {
Icon(
imageVector = if (isExpanded) Icons.Default.KeyboardArrowDown else Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = if (isExpanded) "Collapse" else "Expand",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = label,
style = if (depth == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else if (depth == 0) FontWeight.SemiBold else FontWeight.Normal,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
.padding(end = 16.dp)
)
}
}
@Composable
private fun BookmarksList(
bookmarks: Set<Bookmark>,
onNavigateToBookmark: (Bookmark) -> Unit,
onRenameBookmark: (Bookmark, String) -> Unit,
onDeleteBookmark: (Bookmark) -> Unit
) {
if (bookmarks.isEmpty()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Text(
"You haven't added any bookmarks yet.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
} else {
var bookmarkMenuExpandedFor by remember { mutableStateOf<Bookmark?>(null) }
var showDeleteConfirmDialogFor by remember { mutableStateOf<Bookmark?>(null) }
var showRenameBookmarkDialog by remember { mutableStateOf<Bookmark?>(null) }
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(end = 4.dp)
) {
items(
items = bookmarks.distinctBy { it.cfi }.sortedBy { it.cfi },
key = { it.cfi }
) { bookmark ->
ListItem(
headlineContent = {
Text(
text = bookmark.label?.takeIf { it.isNotBlank() } ?: bookmark.snippet.ifBlank { "Bookmark" },
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyLarge
)
},
supportingContent = {
Column {
Text(
text = bookmark.chapterTitle,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (bookmark.pageInChapter != null && bookmark.totalPagesInChapter != null) {
Text(
text = "Page ${bookmark.pageInChapter} of ${bookmark.totalPagesInChapter}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
trailingContent = {
Box {
IconButton(onClick = { bookmarkMenuExpandedFor = bookmark }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More options for bookmark"
)
}
DropdownMenu(
expanded = bookmarkMenuExpandedFor == bookmark,
onDismissRequest = { bookmarkMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
showRenameBookmarkDialog = bookmark
bookmarkMenuExpandedFor = null
}
)
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showDeleteConfirmDialogFor = bookmark
bookmarkMenuExpandedFor = null
}
)
}
}
},
modifier = Modifier.clickable { onNavigateToBookmark(bookmark) }
)
HorizontalDivider()
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
showRenameBookmarkDialog?.let { bookmarkToRename ->
var newTitle by remember { mutableStateOf("") }
val currentName = bookmarkToRename.label?.takeIf { it.isNotBlank() } ?: bookmarkToRename.snippet
AlertDialog(
onDismissRequest = { showRenameBookmarkDialog = null },
title = { Text("Rename Bookmark") },
text = {
androidx.compose.material3.OutlinedTextField(
value = newTitle,
onValueChange = { newTitle = it },
label = { Text("New Name") },
placeholder = {
Text(
text = currentName,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
},
confirmButton = {
TextButton(
onClick = {
if (newTitle.isNotBlank()) {
onRenameBookmark(bookmarkToRename, newTitle)
}
showRenameBookmarkDialog = null
}
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showRenameBookmarkDialog = null }) {
Text("Cancel")
}
}
)
}
showDeleteConfirmDialogFor?.let { bookmarkToDelete ->
AlertDialog(
onDismissRequest = { showDeleteConfirmDialogFor = null },
title = { Text("Delete Bookmark?") },
text = { Text("Are you sure you want to permanently delete this bookmark?") },
confirmButton = {
TextButton(
onClick = {
onDeleteBookmark(bookmarkToDelete)
showDeleteConfirmDialogFor = null
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showDeleteConfirmDialogFor = null }) {
Text("Cancel")
}
}
)
}
}
}
@Composable
private fun HighlightsList(
userHighlights: List<UserHighlight>,
chapters: List<EpubChapter>,
onNavigateToHighlight: (UserHighlight) -> Unit,
onDeleteHighlight: (UserHighlight) -> Unit
) {
if (userHighlights.isEmpty()) {
Box(modifier = Modifier.fillMaxSize().padding(16.dp), contentAlignment = Alignment.Center) {
Text("No highlights yet.", style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center)
}
} else {
var highlightMenuExpandedFor by remember { mutableStateOf<UserHighlight?>(null) }
var showHighlightDeleteDialogFor by remember { mutableStateOf<UserHighlight?>(null) }
val listState = rememberLazyListState()
Box(modifier = Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().padding(end = 4.dp)
) {
items(
items = userHighlights.sortedBy { it.chapterIndex },
key = { it.id }
) { highlight ->
val chapterTitle = chapters.getOrNull(highlight.chapterIndex)?.title ?: "Unknown Chapter"
ListItem(
headlineContent = {
Text(
text = highlight.text,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
fontWeight = FontWeight.SemiBold
)
},
supportingContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Box(
modifier = Modifier
.size(12.dp)
.background(highlight.color.color, CircleShape)
)
Spacer(Modifier.width(8.dp))
Text(
text = chapterTitle,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
trailingContent = {
Box {
IconButton(onClick = { highlightMenuExpandedFor = highlight }) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Options"
)
}
DropdownMenu(
expanded = highlightMenuExpandedFor == highlight,
onDismissRequest = { highlightMenuExpandedFor = null }
) {
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
showHighlightDeleteDialogFor = highlight
highlightMenuExpandedFor = null
}
)
}
}
},
modifier = Modifier.clickable { onNavigateToHighlight(highlight) }
)
HorizontalDivider()
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
showHighlightDeleteDialogFor?.let { highlightToDelete ->
AlertDialog(
onDismissRequest = { showHighlightDeleteDialogFor = null },
title = { Text("Delete Highlight?") },
text = { Text("Are you sure you want to permanently delete this highlight?") },
confirmButton = {
TextButton(
onClick = {
onDeleteHighlight(highlightToDelete)
showHighlightDeleteDialogFor = null
}
) {
Text("Delete")
}
},
dismissButton = {
TextButton(onClick = { showHighlightDeleteDialogFor = null }) {
Text("Cancel")
}
}
)
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,233 @@
package com.aryan.reader.epubreader
import timber.log.Timber
import android.webkit.WebView
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.aryan.reader.RenderMode
import com.aryan.reader.SearchNavigationControls
import com.aryan.reader.SearchResult
import com.aryan.reader.SearchResultsPanel
import com.aryan.reader.SearchState
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.IPaginator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import java.io.File
import kotlin.math.max
import kotlin.math.min
/**
* Creates the search implementation for EPUB chapters.
*/
fun createEpubSearcher(epubBook: EpubBook): suspend (String) -> List<SearchResult> = { query ->
withContext(Dispatchers.Default) {
val results = mutableListOf<SearchResult>()
epubBook.chapters.forEachIndexed { chapterIndex, chapter ->
try {
val fullPath = "${epubBook.extractionBasePath}/${chapter.htmlFilePath}"
val htmlFile = File(fullPath)
if (!htmlFile.exists()) return@forEachIndexed
val doc = Jsoup.parse(htmlFile, "UTF-8")
val bodyChildren = doc.body().children().toList()
val chunks = bodyChildren.chunked(20)
chunks.forEachIndexed { chunkIndex, chunkOfElements ->
val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
val content = Jsoup.parse(chunkHtml).text()
var lastIndex = -1
while (true) {
lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
if (lastIndex == -1) break
val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
if (isWordStart) {
val snippetStart = max(0, lastIndex - 35)
val snippetEnd = min(content.length, lastIndex + query.length + 35)
val rawSnippet = content.substring(snippetStart, snippetEnd)
val annotatedSnippet = buildAnnotatedString {
append(rawSnippet)
val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
val highlightEnd = highlightStart + query.length
addStyle(
style = SpanStyle(fontWeight = FontWeight.Bold),
start = highlightStart,
end = highlightEnd
)
}
results.add(
SearchResult(
locationInSource = chapterIndex,
locationTitle = chapter.title,
snippet = annotatedSnippet,
query = query,
occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
chunkIndex = chunkIndex
)
)
}
}
}
} catch (e: Exception) {
Timber.e("Failed to search in chapter $chapterIndex", e)
}
}
results
}
}
/**
* Handles the navigation to a specific search result.
*/
fun performSearchResultNavigation(
index: Int,
searchState: SearchState,
renderMode: RenderMode,
currentChapterIndex: Int,
loadedChunkCount: Int,
webView: WebView?,
paginator: IPaginator?,
coroutineScope: CoroutineScope,
onVerticalChapterChange: (chapterIndex: Int, chunkIndex: Int, result: SearchResult) -> Unit,
onVerticalScrollToResult: (result: SearchResult) -> Unit,
onPaginatedScrollToPage: suspend (pageIndex: Int) -> Unit
) {
if (index !in searchState.searchResults.indices) return
val result = searchState.searchResults[index]
searchState.currentSearchResultIndex = index
when (renderMode) {
RenderMode.VERTICAL_SCROLL -> {
if (currentChapterIndex != result.locationInSource) {
onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
} else {
if (result.chunkIndex >= loadedChunkCount) {
onVerticalChapterChange(result.locationInSource, result.chunkIndex, result)
} else {
webView?.let {
val js = "javascript:window.scrollToOccurrence(${result.occurrenceIndexInLocation});"
it.evaluateJavascript(js, null)
}
onVerticalScrollToResult(result)
}
}
}
RenderMode.PAGINATED -> {
paginator?.findPageForSearchResult(result) { pageIndex ->
coroutineScope.launch {
onPaginatedScrollToPage(pageIndex)
}
}
}
}
}
@Composable
fun EpubReaderSearchEffects(
searchState: SearchState,
webViewRef: WebView?,
currentChapterIndex: Int,
focusRequester: FocusRequester
) {
// 1. Auto-Highlight in WebView
LaunchedEffect(searchState.searchResults, currentChapterIndex) {
val query = searchState.searchQuery
if (query.isBlank()) {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
return@LaunchedEffect
}
val resultsInCurrentChapter = searchState.searchResults.any { it.locationInSource == currentChapterIndex }
if (resultsInCurrentChapter) {
webViewRef?.let { webView ->
val escapedQuery = escapeJsString(query)
val js = "javascript:window.highlightAllOccurrences('${escapedQuery}');"
Timber.d("Highligting: $js")
webView.evaluateJavascript(js, null)
}
} else {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
}
}
// 2. Focus Management
LaunchedEffect(searchState.isSearchActive) {
if (searchState.isSearchActive) {
delay(100)
focusRequester.requestFocus()
} else {
webViewRef?.evaluateJavascript("javascript:window.clearSearchHighlights();", null)
}
}
}
@Composable
fun EpubReaderSearchOverlay(
searchState: SearchState,
onNavigateResult: (Int) -> Unit,
bottomPadding: Dp
) {
val keyboardController = LocalSoftwareKeyboardController.current
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
// Search Results Panel
AnimatedVisibility(
visible = searchState.isSearchActive && searchState.showSearchResultsPanel,
enter = slideInVertically { -it } + fadeIn(),
exit = slideOutVertically { -it } + fadeOut(),
) {
SearchResultsPanel(
results = searchState.searchResults,
isSearching = searchState.isSearchInProgress,
onResultClick = { result ->
val resultIndex = searchState.searchResults.indexOf(result)
if (resultIndex != -1) {
onNavigateResult(resultIndex)
}
searchState.showSearchResultsPanel = false
keyboardController?.hide()
},
modifier = Modifier.padding(top = 50.dp)
)
}
AnimatedVisibility(
visible = searchState.isSearchActive && !searchState.showSearchResultsPanel && searchState.hasResults,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(bottom = bottomPadding + 45.dp + 16.dp, end = 16.dp)
) {
SearchNavigationControls(
searchState = searchState,
onNavigate = { index -> onNavigateResult(index) }
)
}
}
}

View file

@ -0,0 +1,497 @@
package com.aryan.reader.epubreader
import android.content.Context
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
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.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity
import java.io.File
const val SETTINGS_PREFS_NAME = "epub_reader_settings"
private const val TEXT_ALIGN_KEY = "reader_text_align"
private const val FONT_SIZE_KEY = "reader_font_size"
private const val LINE_HEIGHT_KEY = "reader_line_height"
private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed"
private const val FONT_FAMILY_KEY = "reader_font_family"
private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
private const val VOLUME_SCROLL_ENABLED_KEY = "volume_scroll_enabled"
const val DEFAULT_FONT_SIZE_VAL = 1.0f
const val DEFAULT_LINE_HEIGHT_VAL = 1.6f
enum class ReaderFont(val id: String, val displayName: String, val fontFamilyName: String) {
ORIGINAL("original", "Original", "Original"),
MERRIWEATHER("merriweather", "Merriweather", "Merriweather"),
LATO("lato", "Lato", "Lato"),
LORA("lora", "Lora", "Lora"),
ROBOTO_MONO("roboto_mono", "Roboto Mono", "Roboto Mono"),
LEXEND("lexend", "Lexend", "Lexend")
}
enum class ReaderTextAlign(val id: String, val cssValue: String, val iconResId: Int, val displayName: String) {
DEFAULT("default", "", R.drawable.format_align_left, "Default"),
LEFT("left", "left", R.drawable.format_align_left, "Left"),
JUSTIFY("justify", "justify", R.drawable.format_align_justify, "Justify")
}
fun getComposeFontFamily(
font: ReaderFont,
customFontPath: String? = null,
assetManager: android.content.res.AssetManager? = null
): FontFamily {
if (customFontPath != null) {
return try {
FontFamily(Font(File(customFontPath)))
} catch (_: Exception) {
FontFamily.Default
}
}
if (assetManager != null) {
return try {
when (font) {
ReaderFont.ORIGINAL -> FontFamily.Default
ReaderFont.MERRIWEATHER -> FontFamily(Font("fonts/merriweather.ttf", assetManager))
ReaderFont.LATO -> FontFamily(Font("fonts/lato.ttf", assetManager))
ReaderFont.LORA -> FontFamily(Font("fonts/lora.ttf", assetManager))
ReaderFont.ROBOTO_MONO -> FontFamily(Font("fonts/roboto_mono.ttf", assetManager))
ReaderFont.LEXEND -> FontFamily(Font("fonts/lexend.ttf", assetManager))
}
} catch (_: Exception) {
FontFamily.Default
}
}
return FontFamily.Default
}
fun loadFontSelection(context: Context): Pair<ReaderFont, String?> {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val savedVal = prefs.getString(FONT_FAMILY_KEY, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
return if (savedVal.startsWith("custom|")) {
val path = savedVal.substringAfter("custom|")
Pair(ReaderFont.ORIGINAL, path)
} else {
val font = ReaderFont.entries.find { it.id == savedVal } ?: ReaderFont.ORIGINAL
Pair(font, null)
}
}
fun saveReaderSettings(
context: Context,
fontSize: Float,
lineHeight: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit {
putFloat(FONT_SIZE_KEY, fontSize)
putFloat(LINE_HEIGHT_KEY, lineHeight)
if (customFontPath != null) {
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
} else {
putString(FONT_FAMILY_KEY, fontFamily.id)
}
putString(TEXT_ALIGN_KEY, textAlign.id)
}
}
fun loadFontSize(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(FONT_SIZE_KEY, DEFAULT_FONT_SIZE_VAL)
}
fun loadLineHeight(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(LINE_HEIGHT_KEY, DEFAULT_LINE_HEIGHT_VAL)
}
fun loadTextAlign(context: Context): ReaderTextAlign {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
val id = prefs.getString(TEXT_ALIGN_KEY, ReaderTextAlign.DEFAULT.id)
return ReaderTextAlign.entries.find { it.id == id } ?: ReaderTextAlign.DEFAULT
}
fun saveAutoScrollSpeed(context: Context, speed: Float) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putFloat(AUTO_SCROLL_SPEED_KEY, speed) }
}
fun loadAutoScrollSpeed(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getFloat(AUTO_SCROLL_SPEED_KEY, 0.8f)
}
fun saveTapToNavigateSetting(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(TAP_TO_NAVIGATE_ENABLED_KEY, enabled) }
}
fun loadTapToNavigateSetting(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(TAP_TO_NAVIGATE_ENABLED_KEY, false)
}
fun saveVolumeScrollSetting(context: Context, enabled: Boolean) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putBoolean(VOLUME_SCROLL_ENABLED_KEY, enabled) }
}
fun loadVolumeScrollSetting(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(VOLUME_SCROLL_ENABLED_KEY, false)
}
@Composable
fun ReaderTextFormatPanel(
isVisible: Boolean,
currentFontSize: Float,
onFontSizeChange: (Float) -> Unit,
currentLineHeight: Float,
onLineHeightChange: (Float) -> Unit,
currentFont: ReaderFont,
currentCustomFontName: String?,
onFontOptionClick: () -> Unit,
currentTextAlign: ReaderTextAlign,
onTextAlignChange: (ReaderTextAlign) -> Unit,
onReset: () -> Unit,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier
) {
Surface(
color = MaterialTheme.colorScheme.surface,
tonalElevation = 8.dp,
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
shadowElevation = 8.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Font Family", style = MaterialTheme.typography.labelLarge)
Surface(
onClick = onFontOptionClick,
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.height(40.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp)
) {
val displayName = currentCustomFontName ?: currentFont.displayName
Text(
text = displayName,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
Spacer(Modifier.width(8.dp))
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(16.dp)
)
}
}
}
HorizontalDivider()
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Font Size", style = MaterialTheme.typography.labelLarge)
Text(
"%.1fx".format(currentFontSize),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = currentFontSize,
onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f,
steps = 24,
modifier = Modifier.fillMaxWidth()
)
}
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Line Spacing", style = MaterialTheme.typography.labelLarge)
Text(
"%.1fx".format(currentLineHeight),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary
)
}
Slider(
value = currentLineHeight,
onValueChange = onLineHeightChange,
valueRange = 1.0f..2.5f,
steps = 14,
modifier = Modifier.fillMaxWidth()
)
}
HorizontalDivider()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box {
var alignmentMenuExpanded by remember { mutableStateOf(false) }
Surface(
onClick = { alignmentMenuExpanded = true },
shape = RoundedCornerShape(8.dp),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(8.dp)
) {
Icon(
painter = androidx.compose.ui.res.painterResource(id = currentTextAlign.iconResId),
contentDescription = "Text Alignment",
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
DropdownMenu(
expanded = alignmentMenuExpanded,
onDismissRequest = { alignmentMenuExpanded = false }
) {
ReaderTextAlign.entries.forEach { align ->
DropdownMenuItem(
text = { Text(align.displayName) },
leadingIcon = {
Icon(
painter = androidx.compose.ui.res.painterResource(id = align.iconResId),
contentDescription = null
)
},
trailingIcon = {
if (align == currentTextAlign) {
Icon(Icons.Default.Check, contentDescription = "Selected")
}
},
onClick = {
onTextAlignChange(align)
alignmentMenuExpanded = false
}
)
}
}
}
TextButton(onClick = onReset) {
Text("Reset Defaults")
}
}
}
}
}
}
@Composable
fun FontSelectionSheetContent(
currentFont: ReaderFont,
currentCustomFontPath: String?,
onFontSelected: (ReaderFont, String?) -> Unit,
customFonts: List<CustomFontEntity>,
onImportFont: (Uri) -> Unit,
onDismiss: () -> Unit
) {
var selectedTabIndex by remember { mutableIntStateOf(0) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let { onImportFont(it) }
}
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("Select Font", style = MaterialTheme.typography.titleMedium)
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, contentDescription = "Close")
}
}
TabRow(selectedTabIndex = selectedTabIndex) {
Tab(selected = selectedTabIndex == 0, onClick = { selectedTabIndex = 0 }, text = { Text("Presets") })
Tab(selected = selectedTabIndex == 1, onClick = { selectedTabIndex = 1 }, text = { Text("Imported") })
}
Box(modifier = Modifier.heightIn(min = 200.dp, max = 400.dp)) {
when (selectedTabIndex) {
0 -> {
LazyColumn(contentPadding = PaddingValues(16.dp)) {
items(ReaderFont.entries.toTypedArray()) { font ->
val isSelected = currentCustomFontPath == null && currentFont == font
ListItem(
headlineContent = {
Text(font.displayName, fontFamily = getComposeFontFamily(font, null))
},
trailingContent = {
if (isSelected) Icon(Icons.Default.Check, contentDescription = "Selected", tint = MaterialTheme.colorScheme.primary)
},
modifier = Modifier.clickable { onFontSelected(font, null) },
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
)
}
}
}
1 -> {
Column(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxWidth().padding(16.dp)) {
Button(
onClick = { launcher.launch(arrayOf("font/ttf", "font/otf", "application/x-font-ttf")) },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Add, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Import from Files")
}
}
if (customFonts.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(
"No imported fonts yet.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 32.dp)
)
}
} else {
LazyColumn(contentPadding = PaddingValues(bottom = 16.dp)) {
items(customFonts) { fontEntity ->
val isSelected = currentCustomFontPath == fontEntity.path
val fontFamily = remember(fontEntity.path) {
try { FontFamily(androidx.compose.ui.text.font.Font(File(fontEntity.path))) } catch(_:Exception) { FontFamily.Default }
}
ListItem(
headlineContent = {
Text(fontEntity.displayName, fontFamily = fontFamily)
},
trailingContent = {
if (isSelected) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary
)
}
},
modifier = Modifier.clickable { onFontSelected(ReaderFont.ORIGINAL, fontEntity.path) },
colors = if (isSelected) ListItemDefaults.colors(containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)) else ListItemDefaults.colors()
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,121 @@
package com.aryan.reader.epubreader
import timber.log.Timber
import android.view.KeyEvent
import android.view.View
import android.view.Window
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.aryan.reader.RenderMode
@Composable
fun EpubReaderSystemUiController(
window: Window?,
view: View,
showBars: Boolean,
initialIsAppearanceLightStatusBars: Boolean,
initialSystemBarsBehavior: Int
) {
val isDarkTheme = isSystemInDarkTheme()
// 1. Handle Immersive Mode (Enter/Exit)
DisposableEffect(window, view, initialIsAppearanceLightStatusBars, initialSystemBarsBehavior) {
if (window == null) {
Timber.w("Window is null, cannot control system UI.")
return@DisposableEffect onDispose {}
}
val insetsController = WindowCompat.getInsetsController(window, view)
Timber.d("Applying immersive mode.")
WindowCompat.setDecorFitsSystemWindows(window, false)
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
onDispose {
Timber.d("Restoring system UI.")
WindowCompat.setDecorFitsSystemWindows(window, true)
insetsController.show(WindowInsetsCompat.Type.navigationBars())
insetsController.isAppearanceLightStatusBars = initialIsAppearanceLightStatusBars
insetsController.systemBarsBehavior = initialSystemBarsBehavior
}
}
// 2. Handle Status Bar Appearance (Dark/Light theme)
LaunchedEffect(window, view, isDarkTheme) {
if (window != null) {
val insetsController = WindowCompat.getInsetsController(window, view)
insetsController.isAppearanceLightStatusBars = !isDarkTheme
}
}
// 3. Handle Show/Hide Bars dynamically
LaunchedEffect(showBars, window, view) {
if (window != null) {
val insetsController = WindowCompat.getInsetsController(window, view)
if (showBars) {
insetsController.show(WindowInsetsCompat.Type.navigationBars())
} else {
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
}
}
}
}
fun Modifier.volumeScrollHandler(
volumeScrollEnabled: Boolean,
renderMode: RenderMode,
isTtsActive: Boolean,
isMusicActive: Boolean,
currentScrollY: Int,
currentScrollHeight: Int,
currentClientHeight: Int,
currentChapterIndex: Int,
totalChapters: Int,
onScrollBy: (Int) -> Unit,
onNavigateChapter: (offset: Int, scrollTarget: ChapterScrollPosition) -> Unit
): Modifier = this.onPreviewKeyEvent { keyEvent ->
val shouldHandle = volumeScrollEnabled &&
renderMode == RenderMode.VERTICAL_SCROLL &&
!isTtsActive &&
!isMusicActive
if (!shouldHandle) return@onPreviewKeyEvent false
val isVolumeKey = keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN ||
keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_UP
if (!isVolumeKey) return@onPreviewKeyEvent false
if (keyEvent.type == KeyEventType.KeyDown) {
val direction = if (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN) 1 else -1
val isAtBottom = (currentScrollY + currentClientHeight) >= (currentScrollHeight - 2)
Timber.d("Dir: $direction, AtBottom: $isAtBottom, Y: $currentScrollY")
if (direction == -1 && currentScrollY == 0) {
// Top -> Prev Chapter
if (currentChapterIndex > 0) {
onNavigateChapter(-1, ChapterScrollPosition.END)
}
} else if (direction == 1 && isAtBottom) {
// Bottom -> Next Chapter
if (currentChapterIndex < totalChapters - 1) {
onNavigateChapter(1, ChapterScrollPosition.START)
}
} else {
// Scroll
val scrollAmount = (currentClientHeight * 0.25).toInt() * direction
onScrollBy(scrollAmount)
}
}
true
}

View file

@ -0,0 +1,319 @@
package com.aryan.reader.epubreader
import android.content.Context
import android.net.Uri
import android.os.Build
import timber.log.Timber
import android.webkit.WebView
import androidx.annotation.OptIn
import androidx.annotation.RequiresApi
import androidx.compose.foundation.pager.PagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.core.content.edit
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.RenderMode
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.paginatedreader.BookPaginator
import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
const val TAG_TTS_DIAGNOSIS = "TTS_DIAGNOSIS"
private const val TTS_MODE_KEY = "tts_mode"
data class TtsHighlightInfo(
val text: String,
val cfi: String,
val offset: Int
)
@Suppress("unused")
@OptIn(UnstableApi::class)
fun saveTtsMode(context: Context, mode: TtsMode) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(TTS_MODE_KEY, mode.name) }
}
@Suppress("unused")
@OptIn(UnstableApi::class)
fun loadTtsMode(): TtsMode {
// For this release, Cloud TTS is disabled. Force BASE mode.
return TtsMode.BASE
}
/**
* Helper to update the WebView auto-scroll state.
*/
fun updateAutoScrollJs(webView: WebView?, playing: Boolean, speed: Float) {
if (playing) {
val jsCommand = "javascript:window.autoScroll.start($speed);"
webView?.evaluateJavascript(jsCommand, null)
} else {
webView?.evaluateJavascript("javascript:window.autoScroll.stop();", null)
}
}
/**
* Logic for triggering the actual TTS start based on the current mode.
*/
fun initiateTtsPlayback(
renderMode: RenderMode,
webView: WebView?,
onPaginatedStart: () -> Unit
) {
when (renderMode) {
RenderMode.VERTICAL_SCROLL -> {
Timber.d("Vertical: requesting text extraction via JS.")
webView?.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null)
}
RenderMode.PAGINATED -> {
onPaginatedStart()
}
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(UnstableApi::class)
@Composable
fun TtsSessionObserver(
ttsState: TtsPlaybackManager.TtsState,
ttsController: TtsController,
currentRenderMode: RenderMode,
chapters: List<EpubChapter>,
epubBookTitle: String,
coverImagePath: String?,
// Vertical Mode Dependencies
webViewRef: WebView?,
loadedChunkCount: Int,
totalChunksInChapter: Int,
// Paginated Mode Dependencies
paginator: IPaginator?,
pagerState: PagerState,
ttsChapterIndex: Int?,
onTtsChapterIndexChange: (Int?) -> Unit,
onNavigateToChapter: (Int) -> Unit,
onToggleTtsStartOnLoad: (Boolean) -> Unit,
userStoppedTts: Boolean,
scope: CoroutineScope
) {
val prevTtsState = remember { mutableStateOf(ttsState) }
LaunchedEffect(ttsState) {
val wasPlaying = prevTtsState.value.isPlaying
val isPlaying = ttsState.isPlaying
val isChangingConfig = ttsState.isChangingConfig
val sessionFinished = ttsState.sessionFinished
val wasSessionFinished = prevTtsState.value.sessionFinished
val sessionEndedByStop = ttsState.sessionEndedByStop
val isReaderSource = ttsState.playbackSource == "READER"
if (!isChangingConfig && isReaderSource) {
if (sessionFinished && !wasSessionFinished) {
Timber.d("TTS finished naturally. Checking for next content.")
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
handleVerticalAutoAdvance(
webViewRef = webViewRef,
loadedChunkCount = loadedChunkCount,
totalChunksInChapter = totalChunksInChapter,
currentTtsChapterIndex = ttsChapterIndex,
totalChapters = chapters.size,
onNavigateToNextChapter = { nextIndex ->
onToggleTtsStartOnLoad(true)
onNavigateToChapter(nextIndex)
},
onStopTts = { onTtsChapterIndexChange(null) }
)
} else if (currentRenderMode == RenderMode.PAGINATED) {
handlePaginatedAutoAdvance(
ttsController = ttsController,
paginator = paginator,
pagerState = pagerState,
chapters = chapters,
currentTtsChapterIndex = ttsChapterIndex,
epubBookTitle = epubBookTitle,
coverImagePath = coverImagePath,
onUpdateTtsChapter = onTtsChapterIndexChange,
scope = scope
)
}
} else if (wasPlaying && !isPlaying && !sessionFinished) {
// Playback stopped/paused
if (userStoppedTts || sessionEndedByStop) {
Timber.d("TTS stopped by user/stop command.")
onTtsChapterIndexChange(null)
}
}
}
prevTtsState.value = ttsState
}
}
/**
* Handles highlighting text in WebView (Vertical) or turning pages (Paginated)
* based on playback progress.
*/
@OptIn(UnstableApi::class)
@Composable
fun TtsHighlightHandler(
ttsState: TtsPlaybackManager.TtsState,
currentRenderMode: RenderMode,
webViewRef: WebView?,
paginator: IPaginator?,
pagerState: PagerState,
ttsChapterIndex: Int?,
scope: CoroutineScope
) {
// 1. Vertical & General Highlighting (WebView)
LaunchedEffect(ttsState.currentText, ttsState.sourceCfi, ttsState.startOffsetInSource, webViewRef) {
val text = ttsState.currentText
val cfi = ttsState.sourceCfi
val offset = ttsState.startOffsetInSource
if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) {
val escapedText = escapeJsString(text)
val escapedCfi = escapeJsString(cfi)
// Use window.highlightFromCfi defined in epub_reader.js
val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);"
webViewRef?.evaluateJavascript(jsCommand, null)
} else {
if (!ttsState.isPlaying && !ttsState.isLoading) {
webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null)
}
}
}
// 2. Paginated Page Turning (Sentence/Fragment level)
LaunchedEffect(ttsState.sourceCfi, ttsState.startOffsetInSource, paginator, ttsChapterIndex) {
if (currentRenderMode != RenderMode.PAGINATED) return@LaunchedEffect
val cfi = ttsState.sourceCfi ?: return@LaunchedEffect
val offset = ttsState.startOffsetInSource.takeIf { it != -1 } ?: return@LaunchedEffect
val chapterIdx = ttsChapterIndex ?: return@LaunchedEffect
val pag = paginator ?: return@LaunchedEffect
val targetPage = pag.findPageForCfiAndOffset(chapterIdx, cfi, offset)
if (targetPage != null && targetPage != pagerState.currentPage) {
// Prevent backward jumps during reading (unless significant) to avoid jitter
if (targetPage >= pagerState.currentPage) {
scope.launch {
pagerState.animateScrollToPage(targetPage)
}
}
}
}
}
// --- Internal Helper Functions ---
private fun handleVerticalAutoAdvance(
webViewRef: WebView?,
loadedChunkCount: Int,
totalChunksInChapter: Int,
currentTtsChapterIndex: Int?,
totalChapters: Int,
onNavigateToNextChapter: (Int) -> Unit,
onStopTts: () -> Unit
) {
if (loadedChunkCount < totalChunksInChapter) {
Timber.d("Vertical: Loading next chunk for TTS.")
webViewRef?.evaluateJavascript("javascript:window.virtualization.loadNextChunk();", null)
android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
webViewRef?.evaluateJavascript("javascript:TtsBridgeHelper.extractAndRelayText();", null)
}, 500)
} else {
if (currentTtsChapterIndex != null && currentTtsChapterIndex < totalChapters - 1) {
Timber.d("Vertical: Chapter finished, moving to next.")
onNavigateToNextChapter(currentTtsChapterIndex + 1)
} else {
Timber.d("Vertical: End of book.")
onStopTts()
}
}
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@OptIn(UnstableApi::class)
private fun handlePaginatedAutoAdvance(
ttsController: TtsController,
paginator: IPaginator?,
pagerState: PagerState,
chapters: List<EpubChapter>,
currentTtsChapterIndex: Int?,
epubBookTitle: String,
coverImagePath: String?,
onUpdateTtsChapter: (Int?) -> Unit,
scope: CoroutineScope
) {
val lastPlayedChapter = currentTtsChapterIndex
if (lastPlayedChapter != null && lastPlayedChapter < chapters.size - 1) {
Timber.d("Paginated: Searching for next TTS content...")
scope.launch {
var chapterToTry = lastPlayedChapter + 1
var foundContent = false
val bookPaginator = paginator as? BookPaginator
if (bookPaginator == null) {
onUpdateTtsChapter(null)
return@launch
}
while (chapterToTry < chapters.size) {
// Visually scroll to start of chapter
val targetPage = bookPaginator.chapterStartPageIndices[chapterToTry]
if (targetPage != null && pagerState.currentPage != targetPage) {
pagerState.animateScrollToPage(targetPage)
delay(300)
}
val nextChapterChunks = bookPaginator.getTtsChunksForChapter(chapterToTry)
if (!nextChapterChunks.isNullOrEmpty()) {
Timber.d("Paginated: Found content in chapter $chapterToTry. Starting.")
onUpdateTtsChapter(chapterToTry)
val chapterTitle = chapters.getOrNull(chapterToTry)?.title
val coverUriString = coverImagePath?.let { Uri.fromFile(File(it)).toString() }
ttsController.start(
chunks = nextChapterChunks,
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
ttsMode = "BASE" // Defaulting to Base for safety
)
foundContent = true
break
} else {
Timber.d("Paginated: Chapter $chapterToTry is empty. Skipping.")
// Visually flip through empty pages if needed
val pageCount = bookPaginator.chapterPageCounts[chapterToTry] ?: 0
if (pageCount > 1) {
for (i in 1 until pageCount) {
pagerState.animateScrollToPage(targetPage!! + i)
delay(400)
}
}
chapterToTry++
}
}
if (!foundContent) {
Timber.d("Paginated: No more content found.")
onUpdateTtsChapter(null)
}
}
} else {
onUpdateTtsChapter(null)
}
}

View file

@ -0,0 +1,121 @@
package com.aryan.reader.epubreader
import timber.log.Timber
import android.webkit.JavascriptInterface
import androidx.compose.foundation.layout.Column
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.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.json.JSONObject
import kotlin.math.min
val DRAG_TO_CHANGE_CHAPTER_THRESHOLD_DP = 100.dp
val PAGE_INFO_BAR_HEIGHT = 25.dp
@Composable
fun ChapterChangeIndicator(
text: String,
progress: Float,
isPullingDown: Boolean,
modifier: Modifier = Modifier
) {
val alpha = min(1f, progress * 1.5f)
if (alpha > 0.1f) {
Surface(
modifier = modifier
.fillMaxWidth()
.alpha(alpha)
.padding(horizontal = 16.dp),
shape = MaterialTheme.shapes.medium,
color = MaterialTheme.colorScheme.inverseSurface.copy(alpha = 0.5f),
tonalElevation = 4.dp
) {
Column(
modifier = Modifier.padding(vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = if (isPullingDown) Icons.AutoMirrored.Filled.ArrowBack else Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = null,
tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(20.dp * min(1f, progress + 0.2f))
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = if (progress >= 1.0f) text else "Pull further... (${(progress * 100).toInt()}%)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.inverseOnSurface,
textAlign = TextAlign.Center
)
}
}
}
}
enum class ChapterScrollPosition {
START, END
}
data class SelectionRect(
val x: Float,
val y: Float,
val width: Float,
val height: Float,
val text: String
)
interface TextSelectionListener {
fun onTextSelected(rect: SelectionRect)
fun onSelectionCleared()
}
@Suppress("unused")
class TextSelectionJsInterface(private val listener: TextSelectionListener) {
@JavascriptInterface
fun onTextSelected(rectJson: String) {
try {
val json = JSONObject(rectJson)
val rect = SelectionRect(
x = json.getDouble("x").toFloat(),
y = json.getDouble("y").toFloat(),
width = json.getDouble("width").toFloat(),
height = json.getDouble("height").toFloat(),
text = json.getString("text")
)
listener.onTextSelected(rect)
} catch (e: Exception) {
Timber.e(e, "Error parsing selection rect JSON")
}
}
@JavascriptInterface
fun onSelectionCleared() {
listener.onSelectionCleared()
}
}
class PageInfoBridge(
private val onUpdate: (scrollY: Int, scrollHeight: Int, clientHeight: Int, activeFragmentId: String?) -> Unit
) {
@JavascriptInterface
fun updateScrollState(scrollY: Int, scrollHeight: Int, clientHeight: Int, activeFragmentId: String?) {
val fragment = if (activeFragmentId == "null" || activeFragmentId.isNullOrBlank()) null else activeFragmentId
Timber.tag("FRAG_NAV_DEBUG").d("Bridge received fragmentId: $fragment")
onUpdate(scrollY, scrollHeight, clientHeight, fragment)
}
}

View file

@ -0,0 +1,302 @@
// InteractiveWebView.kt
package com.aryan.reader.epubreader
import android.annotation.SuppressLint
import android.content.Context
import timber.log.Timber
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.webkit.WebView
import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import android.view.View
import org.json.JSONObject
enum class DragOperation { NONE, PULLING_DOWN_FROM_TOP, PULLING_UP_FROM_BOTTOM }
@SuppressLint("ViewConstructor")
class InteractiveWebView(
context: Context,
private val onSingleTap: () -> Unit,
private val onPotentialScroll: () -> Unit,
private val onOverScrollTop: (dragAmount: Float) -> Unit,
private val onOverScrollBottom: (dragAmount: Float) -> Unit,
private val onReleaseOverScrollTop: () -> Unit,
private val onReleaseOverScrollBottom: () -> Unit,
private val onShowCustomSelectionMenu: (selectedText: String, selectionBounds: Rect, finishActionModeCallback: () -> Unit) -> Unit,
private val onHideCustomSelectionMenu: () -> Unit
) : WebView(context) {
companion object {
private const val DRAG_SENSITIVITY_PX = 20f
}
private var startY: Float = 0f
private var initialDragY: Float = 0f
private var currentDragOperation: DragOperation = DragOperation.NONE
private val scrollStopHandler = Handler(Looper.getMainLooper())
private var scrollStopRunnable: Runnable? = null
private var mCustomCallback: ActionMode.Callback? = null
private val gestureDetector =
GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onDown(e: MotionEvent): Boolean {
return true
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
Timber.d("onSingleTapConfirmed")
onSingleTap()
return true
}
})
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
gestureDetector.onTouchEvent(event)
var overscrollEventHandled = false
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
startY = event.y
}
MotionEvent.ACTION_MOVE -> {
onPotentialScroll()
val deltaYSinceActionDown = event.y - startY
val oldDragOperation = currentDragOperation
if (currentDragOperation == DragOperation.PULLING_DOWN_FROM_TOP) {
val dragDistance = event.y - initialDragY
onOverScrollTop(dragDistance.coerceAtLeast(0f))
overscrollEventHandled = true
} else if (currentDragOperation == DragOperation.PULLING_UP_FROM_BOTTOM) {
val dragDistance = initialDragY - event.y
onOverScrollBottom(dragDistance.coerceAtLeast(0f))
overscrollEventHandled = true
} else {
if (deltaYSinceActionDown > DRAG_SENSITIVITY_PX && !canScrollVertically(-1)) {
currentDragOperation = DragOperation.PULLING_DOWN_FROM_TOP
initialDragY = event.y
onOverScrollTop(0f)
overscrollEventHandled = true
} else if (deltaYSinceActionDown < -DRAG_SENSITIVITY_PX && !canScrollVertically(
1
)
) {
currentDragOperation = DragOperation.PULLING_UP_FROM_BOTTOM
initialDragY = event.y
onOverScrollBottom(0f)
overscrollEventHandled = true
}
}
if (currentDragOperation != DragOperation.NONE && oldDragOperation == DragOperation.NONE) {
Timber.d("Drag operation started ($currentDragOperation), disabling text selection."
)
evaluateJavascript(
"javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(false);",
null
)
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val wasDragging = currentDragOperation != DragOperation.NONE
if (currentDragOperation == DragOperation.PULLING_DOWN_FROM_TOP) {
onReleaseOverScrollTop()
overscrollEventHandled = true
} else if (currentDragOperation == DragOperation.PULLING_UP_FROM_BOTTOM) {
onReleaseOverScrollBottom()
overscrollEventHandled = true
}
currentDragOperation = DragOperation.NONE
if (wasDragging) {
Timber.d("Drag operation ended, enabling text selection.")
evaluateJavascript("javascript:if(window.setTextSelectionEnabled) window.setTextSelectionEnabled(true);", null)
}
}
}
parent?.requestDisallowInterceptTouchEvent(currentDragOperation != DragOperation.NONE)
if (overscrollEventHandled) {
return true
}
return super.onTouchEvent(event)
}
override fun startActionMode(originalCallback: ActionMode.Callback, type: Int): ActionMode? {
if (type == ActionMode.TYPE_FLOATING) {
if (mCustomCallback == null) {
mCustomCallback = object : ActionMode.Callback2() {
override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean {
Timber.d("CustomSelection: onCreateActionMode")
menu.clear()
return true
}
override fun onPrepareActionMode(mode: ActionMode, menu: Menu): Boolean {
Timber.d("CustomSelection: onPrepareActionMode")
menu.clear()
val jsToGetSelectionDetails = """
(function() {
var selection = window.getSelection();
var selectedText = selection.toString().trim();
if (selectedText.length === 0 || selection.rangeCount === 0) {
return null;
}
var range = selection.getRangeAt(0);
var rect = range.getBoundingClientRect();
// If getBoundingClientRect returns all zeros, try getClientRects()
if (rect.width === 0 && rect.height === 0 && rect.top === 0 && rect.left === 0) {
var clientRects = range.getClientRects();
if (clientRects.length > 0) {
rect = clientRects[0]; // Use the first rect
} else {
return null; // No valid rect found
}
}
// Ensure the rect has some dimension
if (rect.width === 0 && rect.height === 0) {
return null;
}
return JSON.stringify({
text: selectedText,
left: rect.left,
top: rect.top,
right: rect.right,
bottom: rect.bottom,
width: rect.width,
height: rect.height
});
})();
""".trimIndent()
this@InteractiveWebView.evaluateJavascript(jsToGetSelectionDetails) { jsonResult ->
if (jsonResult == null || jsonResult == "null" || jsonResult.equals("\"null\"", ignoreCase = true)) {
Timber.d("CustomSelection: JS returned null or invalid for selection details.")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
try {
val unquotedJsonResult = jsonResult.removeSurrounding("\"")
.replace("\\\"", "\"")
.replace("\\\\", "\\")
val selectionDetails = JSONObject(unquotedJsonResult)
val selectedText = selectionDetails.getString("text")
if (selectedText.isBlank()) {
Timber.d("CustomSelection: Selected text is blank after JS processing.")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
val jsLeft = selectionDetails.getDouble("left")
val jsTop = selectionDetails.getDouble("top")
val jsRight = selectionDetails.getDouble("right")
val jsBottom = selectionDetails.getDouble("bottom")
val jsWidth = selectionDetails.getDouble("width")
val jsHeight = selectionDetails.getDouble("height")
if (jsWidth == 0.0 && jsHeight == 0.0) {
Timber.d("CustomSelection: JS returned a zero-area rect (width=0, height=0). Left: $jsLeft, Top: $jsTop")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
val density = context.resources.displayMetrics.density
val webViewLocation = IntArray(2)
this@InteractiveWebView.getLocationOnScreen(webViewLocation)
val webViewX = webViewLocation[0]
val webViewY = webViewLocation[1]
val selectionRectScreen = Rect(
(webViewX + jsLeft * density).toInt(),
(webViewY + jsTop * density).toInt(),
(webViewX + jsRight * density).toInt(),
(webViewY + jsBottom * density).toInt()
)
if (selectionRectScreen.isEmpty || selectionRectScreen.width() <= 0 || selectionRectScreen.height() <= 0) {
Timber.d("CustomSelection: Calculated selectionRectScreen is empty or invalid: $selectionRectScreen. JS LTRB: $jsLeft, $jsTop, $jsRight, $jsBottom. WebViewLoc: $webViewX, $webViewY")
onHideCustomSelectionMenu()
mode.finish()
return@evaluateJavascript
}
Timber.d("CustomSelection: Selected text: '$selectedText', JS Rect: {L:$jsLeft, T:$jsTop, R:$jsRight, B:$jsBottom}, Screen Rect: $selectionRectScreen")
onShowCustomSelectionMenu(selectedText, selectionRectScreen) {
mode.finish()
}
} catch (e: Exception) {
Timber.e(e, "CustomSelection: Error parsing selection details from JS: '$jsonResult', raw: '$jsonResult'")
onHideCustomSelectionMenu()
mode.finish()
}
}
return true
}
override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean {
Timber.d("CustomSelection: onActionItemClicked (should not be called as menu is empty)")
return false
}
override fun onDestroyActionMode(mode: ActionMode) {
Timber.d("CustomSelection: onDestroyActionMode for mode: $mode")
onHideCustomSelectionMenu()
}
override fun onGetContentRect(mode: ActionMode, view: View, outRect: Rect) {
super.onGetContentRect(mode, view, outRect)
Timber.d("CustomSelection: onGetContentRect called by system. outRect: $outRect")
}
}
}
return super.startActionMode(mCustomCallback, type)
}
return super.startActionMode(originalCallback, type)
}
override fun onScrollChanged(l: Int, t: Int, oldl: Int, oldt: Int) {
super.onScrollChanged(l, t, oldl, oldt)
scrollStopRunnable?.let { scrollStopHandler.removeCallbacks(it) }
scrollStopRunnable = Runnable {
evaluateJavascript("(function() { return window.getSelection().toString(); })();") { result ->
val selectedText = result?.removeSurrounding("\"")
if (!selectedText.isNullOrBlank()) {
Timber.d("Selection exists after scroll. Restarting action mode.")
mCustomCallback?.let {
startActionMode(it, ActionMode.TYPE_FLOATING)
}
}
}
}
scrollStopRunnable?.let { scrollStopHandler.postDelayed(it, 250) }
}
}

View file

@ -0,0 +1,689 @@
package com.aryan.reader.feedback
import android.net.Uri
import timber.log.Timber
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.PickVisualMediaRequest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavHostController
import coil.compose.AsyncImage
import coil.compose.SubcomposeAsyncImage
import coil.request.ImageRequest
import com.aryan.reader.R
import com.aryan.reader.data.FeedbackMessage
import com.aryan.reader.data.FeedbackThread
import java.text.SimpleDateFormat
import java.util.Locale
import android.content.Intent
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.outlined.Email
import androidx.core.net.toUri
private fun launchEmailFeedback(context: android.content.Context) {
val intent = Intent(Intent.ACTION_SENDTO).apply {
data = "mailto:epistemereader@gmail.com".toUri()
putExtra(Intent.EXTRA_SUBJECT, "Feedback: Episteme Reader")
}
try {
context.startActivity(intent)
} catch (e: Exception) {
Timber.e(e, "Could not launch email intent")
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FeedbackScreen(
navController: NavHostController,
viewModel: FeedbackViewModel = viewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val snackbarHostState = remember { SnackbarHostState() }
val activeThreadsCount = remember(uiState.threads) { uiState.threads.count { it.status == "open" } }
val isLimitReached = activeThreadsCount >= 3
// State for Tabs
var selectedTabIndex by remember { mutableIntStateOf(0) }
LaunchedEffect(uiState.errorMessage) {
uiState.errorMessage?.let {
snackbarHostState.showSnackbar(it)
viewModel.clearError()
}
}
BackHandler(enabled = uiState.selectedThreadId != null) {
viewModel.onBackToThreadList()
}
// Helper to determine if current chat is closed
val currentThread = remember(uiState.selectedThreadId, uiState.threads) {
uiState.threads.find { it.id == uiState.selectedThreadId }
}
val isCurrentChatClosed = currentThread?.status == "closed"
Scaffold(
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
TopAppBar(
title = {
Text(if (uiState.selectedThreadId == null) "Help & Feedback" else "Support Chat")
},
navigationIcon = {
IconButton(onClick = {
if (uiState.selectedThreadId != null) {
viewModel.onBackToThreadList()
} else {
navController.popBackStack()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
if (uiState.selectedThreadId == null) {
IconButton(onClick = { launchEmailFeedback(context) }) {
Icon(
imageVector = Icons.Outlined.Email,
contentDescription = "Send Email Feedback"
)
}
}
}
)
},
floatingActionButton = {
if (uiState.selectedThreadId == null && selectedTabIndex == 0) {
Column(horizontalAlignment = Alignment.End) {
ExtendedFloatingActionButton(
onClick = { if (!isLimitReached) viewModel.onStartCreateTicket() },
icon = { Icon(Icons.Default.Add, "New Ticket") },
text = { Text("New Ticket") },
containerColor = if (isLimitReached) MaterialTheme.colorScheme.surfaceVariant else MaterialTheme.colorScheme.primaryContainer,
contentColor = if (isLimitReached) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
) { paddingValues ->
Box(modifier = Modifier.padding(paddingValues)) {
if (uiState.selectedThreadId == null) {
// TABS & LIST
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = selectedTabIndex) {
Tab(
selected = selectedTabIndex == 0,
onClick = { selectedTabIndex = 0 },
text = { Text("Active") }
)
Tab(
selected = selectedTabIndex == 1,
onClick = { selectedTabIndex = 1 },
text = { Text("Closed") }
)
}
// Limit Hint Text
if (selectedTabIndex == 0 && isLimitReached) {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Limit of 3 active tickets reached.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
modifier = Modifier.padding(vertical = 8.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
val displayedThreads = if (selectedTabIndex == 0) {
uiState.threads.filter { it.status == "open" }
} else {
uiState.threads.filter { it.status == "closed" }
}
ThreadList(
threads = displayedThreads,
onThreadClick = { viewModel.onThreadSelected(it.id) },
emptyMessage = if (selectedTabIndex == 0) "No active tickets" else "No closed tickets",
onEmailClick = { launchEmailFeedback(context) }
)
}
} else {
ChatView(
messages = uiState.currentMessages,
pendingMessages = uiState.pendingMessages,
inputMessage = uiState.chatInputMessage,
inputAttachments = uiState.chatInputAttachments,
onInputChange = { viewModel.onChatInputChange(it) },
onAttachmentsSelected = { viewModel.onChatImagesSelected(it) },
onRemoveAttachment = { viewModel.onRemoveChatImage(it) },
onSend = { viewModel.onSendMessage() },
isClosed = isCurrentChatClosed
)
}
if (uiState.isLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth().align(Alignment.TopCenter))
}
}
}
if (uiState.isCreatingTicket) {
CreateTicketDialog(
message = uiState.newTicketMessage,
category = uiState.newTicketCategory,
attachments = uiState.newTicketAttachments,
onMessageChange = viewModel::onNewTicketMessageChange,
onCategoryChange = viewModel::onNewTicketCategoryChange,
onAttachmentsSelected = viewModel::onNewTicketImagesSelected,
onRemoveAttachment = viewModel::onRemoveNewTicketImage,
onSubmit = viewModel::onSubmitTicket,
onDismiss = viewModel::onCancelCreateTicket
)
}
}
@Composable
fun ThreadList(
threads: List<FeedbackThread>,
onThreadClick: (FeedbackThread) -> Unit,
emptyMessage: String = "No conversations yet",
onEmailClick: () -> Unit
) {
if (threads.isEmpty()) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = emptyMessage,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Prefer email or can't sign in?",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
TextButton(onClick = onEmailClick) {
Icon(
Icons.Default.Email,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Contact via Email")
}
}
}
}
} else {
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(threads, key = { it.id }) { thread ->
ThreadItem(thread = thread, onClick = { onThreadClick(thread) })
HorizontalDivider()
}
}
}
}
@Composable
fun ThreadItem(
thread: FeedbackThread,
onClick: () -> Unit
) {
val dateStr = remember(thread.lastUpdated) {
thread.lastUpdated?.let {
SimpleDateFormat("MMM d", Locale.getDefault()).format(it)
} ?: "Just now"
}
val itemAlpha = if (thread.status == "closed") 0.6f else 1f
ListItem(
modifier = Modifier
.clickable(onClick = onClick)
.graphicsLayer { alpha = itemAlpha },
headlineContent = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(thread.category, fontWeight = FontWeight.Bold)
if (thread.hasUnreadAdminReply) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier = Modifier
.size(8.dp)
.background(MaterialTheme.colorScheme.error, CircleShape)
)
}
}
},
supportingContent = {
val isPreviewEmpty = thread.preview.isBlank()
val previewText = if (isPreviewEmpty) "Attached Image" else thread.preview
Text(
text = previewText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
fontStyle = if (isPreviewEmpty) FontStyle.Italic else FontStyle.Normal
)
},
trailingContent = {
Text(dateStr, style = MaterialTheme.typography.labelSmall)
}
)
}
@Composable
fun ChatView(
messages: List<FeedbackMessage>,
pendingMessages: List<FeedbackMessage>,
inputMessage: String,
inputAttachments: List<Uri>,
onInputChange: (String) -> Unit,
onAttachmentsSelected: (List<Uri>) -> Unit,
onRemoveAttachment: (Uri) -> Unit,
onSend: () -> Unit,
isClosed: Boolean = false
) {
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(5),
onResult = { uris -> if (uris.isNotEmpty()) onAttachmentsSelected(uris) }
)
val listState = rememberLazyListState()
val displayedMessages = remember(messages, pendingMessages) {
val realIds = messages.map { it.id }.toSet()
val uniquePending = pendingMessages.filter { it.id !in realIds }
Timber.d("ChatView Recomposition: ${messages.size} real, ${uniquePending.size} pending unique")
messages + uniquePending
}
Column(modifier = Modifier.fillMaxSize()) {
LaunchedEffect(displayedMessages.size) {
if (displayedMessages.isNotEmpty()) {
listState.animateScrollToItem(displayedMessages.size - 1)
}
}
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 16.dp),
contentPadding = PaddingValues(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
items(displayedMessages, key = { it.id }) { msg ->
MessageBubble(
message = msg,
modifier = Modifier.animateItem(fadeInSpec = null, fadeOutSpec = null)
)
}
}
Surface(
tonalElevation = 2.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.fillMaxWidth()) {
if (isClosed) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(24.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "This ticket is closed.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium
)
}
} else {
if (inputAttachments.isNotEmpty()) {
LazyRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 8.dp, end = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(inputAttachments) { uri ->
Box(modifier = Modifier.size(60.dp)) {
AsyncImage(
model = uri,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(8.dp))
)
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), CircleShape)
.padding(2.dp)
.clickable { onRemoveAttachment(uri) },
tint = androidx.compose.ui.graphics.Color.White
)
}
}
}
}
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { imagePickerLauncher.launch(PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly)) }) {
Icon(
painter = painterResource(id = R.drawable.image),
contentDescription = "Add Image",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
OutlinedTextField(
value = inputMessage,
onValueChange = onInputChange,
modifier = Modifier.weight(1f),
placeholder = { Text("Type a message...") },
maxLines = 3,
shape = RoundedCornerShape(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
IconButton(
onClick = onSend,
enabled = inputMessage.isNotBlank() || inputAttachments.isNotEmpty()
) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
tint = if (inputMessage.isNotBlank() || inputAttachments.isNotEmpty()) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline
)
}
}
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MessageBubble(
message: FeedbackMessage,
modifier: Modifier = Modifier
) {
val isMe = message.sender == "user"
val alignment = if (isMe) Alignment.End else Alignment.Start
val color = if (isMe) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer
val textColor = if (isMe) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = alignment
) {
Surface(
shape = RoundedCornerShape(
topStart = 16.dp,
topEnd = 16.dp,
bottomStart = if (isMe) 16.dp else 4.dp,
bottomEnd = if (isMe) 4.dp else 16.dp
),
color = color,
modifier = Modifier.widthIn(max = 280.dp)
) {
Column(modifier = Modifier.padding(12.dp)) {
if (message.attachments.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(bottom = if(message.text.isNotEmpty()) 8.dp else 0.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
message.attachments.forEach { url ->
SubcomposeAsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(url)
.crossfade(true)
.build(),
contentDescription = "Attachment",
modifier = Modifier
.size(100.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentScale = ContentScale.Crop,
error = {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(id = R.drawable.broken_image),
contentDescription = "Image unavailable",
modifier = Modifier.padding(24.dp).fillMaxSize(),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
}
}
)
}
}
}
if (message.text.isNotEmpty()) {
Text(
text = message.text,
color = textColor
)
}
}
}
message.timestamp?.let {
Text(
text = SimpleDateFormat("h:mm a", Locale.getDefault()).format(it),
style = MaterialTheme.typography.labelSmall,
modifier = Modifier.padding(top = 4.dp),
color = MaterialTheme.colorScheme.outline
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun CreateTicketDialog(
message: String,
category: String,
attachments: List<Uri>,
onMessageChange: (String) -> Unit,
onCategoryChange: (String) -> Unit,
onAttachmentsSelected: (List<Uri>) -> Unit,
onRemoveAttachment: (Uri) -> Unit,
onSubmit: () -> Unit,
onDismiss: () -> Unit
) {
val imagePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickMultipleVisualMedia(3),
onResult = { uris -> if (uris.isNotEmpty()) onAttachmentsSelected(uris) }
)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("New Ticket") },
text = {
Column {
val categories = listOf("Bug Report", "Feature Request", "Feedback", "Other")
Text("Category", style = MaterialTheme.typography.labelLarge)
Row(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
categories.take(2).forEach { cat ->
FilterChip(
selected = category == cat,
onClick = { onCategoryChange(cat) },
label = { Text(cat) },
modifier = Modifier.padding(end = 8.dp)
)
}
}
OutlinedTextField(
value = message,
onValueChange = onMessageChange,
label = { Text("Describe your issue...") },
modifier = Modifier
.fillMaxWidth()
.height(120.dp),
shape = RoundedCornerShape(8.dp)
)
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
TextButton(
onClick = { imagePickerLauncher.launch(PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly)) }
) {
Icon(painter = painterResource(id = R.drawable.image), contentDescription = null)
Spacer(modifier = Modifier.width(4.dp))
Text("Add Image")
}
Spacer(modifier = Modifier.weight(1f))
Text(
"${attachments.size}/3",
style = MaterialTheme.typography.labelSmall
)
}
if (attachments.isNotEmpty()) {
LazyRow(
modifier = Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(attachments) { uri ->
Box(modifier = Modifier.size(60.dp)) {
AsyncImage(
model = uri,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.matchParentSize()
.clip(RoundedCornerShape(8.dp))
)
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier
.size(20.dp)
.align(Alignment.TopEnd)
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.5f), CircleShape)
.padding(2.dp)
.clickable { onRemoveAttachment(uri) },
tint = androidx.compose.ui.graphics.Color.White
)
}
}
}
}
}
},
confirmButton = {
TextButton(
onClick = onSubmit,
enabled = message.isNotBlank()
) {
Text("Submit")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
}

View file

@ -0,0 +1,274 @@
package com.aryan.reader.feedback
import android.app.Application
import android.content.Context
import android.net.Uri
import timber.log.Timber
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.aryan.reader.AuthRepository
import com.aryan.reader.data.FeedbackMessage
import com.aryan.reader.data.FeedbackRepository
import com.aryan.reader.data.FeedbackThread
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import java.util.Date
data class FeedbackUiState(
val threads: List<FeedbackThread> = emptyList(),
val currentMessages: List<FeedbackMessage> = emptyList(),
val pendingMessages: List<FeedbackMessage> = emptyList(),
val selectedThreadId: String? = null,
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isCreatingTicket: Boolean = false,
val newTicketMessage: String = "",
val newTicketCategory: String = "Bug Report",
val newTicketAttachments: List<Uri> = emptyList(),
val chatInputMessage: String = "",
val chatInputAttachments: List<Uri> = emptyList()
)
class FeedbackViewModel(application: Application) : AndroidViewModel(application) {
private val repository = FeedbackRepository(application)
private val authRepository = AuthRepository(application)
private val _uiState = MutableStateFlow(FeedbackUiState())
val uiState = _uiState.asStateFlow()
private var threadsListener: Any? = null
private var messagesListener: Any? = null
private val currentUser = authRepository.getSignedInUser()
init {
if (currentUser != null) {
startListeningToThreads(currentUser.uid)
} else {
_uiState.update { it.copy(errorMessage = "You must be signed in to use feedback.") }
}
}
private fun startListeningToThreads(userId: String) {
_uiState.update { it.copy(isLoading = true) }
threadsListener = repository.listenToFeedbackThreads(userId) { threads ->
_uiState.update { it.copy(threads = threads, isLoading = false) }
}
}
fun onThreadSelected(threadId: String) {
Timber.d("VM: onThreadSelected $threadId")
repository.removeListener(messagesListener)
_uiState.update {
it.copy(
selectedThreadId = threadId,
isLoading = true,
currentMessages = emptyList(),
pendingMessages = emptyList(),
chatInputMessage = "",
chatInputAttachments = emptyList()
)
}
viewModelScope.launch {
repository.markThreadAsRead(threadId)
}
messagesListener = repository.listenToMessages(threadId) { messages ->
Timber.d("VM: Listener update received. ${messages.size} messages.")
viewModelScope.launch {
repository.markThreadAsRead(threadId)
}
_uiState.update { state ->
val realIds = messages.map { it.id }.toSet()
val remainingPending = state.pendingMessages.filter { it.id !in realIds }
if (remainingPending.size != state.pendingMessages.size) {
Timber.d("VM: Reconciliation - Removed ${state.pendingMessages.size - remainingPending.size} pending messages.")
}
state.copy(
currentMessages = messages,
pendingMessages = remainingPending,
isLoading = false
)
}
}
}
fun onBackToThreadList() {
Timber.d("VM: Back to thread list")
repository.removeListener(messagesListener)
messagesListener = null
_uiState.update { it.copy(selectedThreadId = null, currentMessages = emptyList(), pendingMessages = emptyList()) }
}
fun onStartCreateTicket() {
if (currentUser == null) {
_uiState.update { it.copy(errorMessage = "You must be signed in to submit feedback.") }
return
}
_uiState.update { it.copy(isCreatingTicket = true) }
}
fun onCancelCreateTicket() {
_uiState.update { it.copy(isCreatingTicket = false, newTicketMessage = "", newTicketAttachments = emptyList()) }
}
fun onNewTicketMessageChange(text: String) {
_uiState.update { it.copy(newTicketMessage = text) }
}
fun onNewTicketCategoryChange(category: String) {
_uiState.update { it.copy(newTicketCategory = category) }
}
fun onNewTicketImagesSelected(uris: List<Uri>) {
val current = _uiState.value.newTicketAttachments
if (current.size + uris.size > 3) {
_uiState.update { it.copy(errorMessage = "Max 3 images allowed for tickets.") }
return
}
validateAndAddImages(uris) { validUris ->
_uiState.update { it.copy(newTicketAttachments = it.newTicketAttachments + validUris) }
}
}
fun onRemoveNewTicketImage(uri: Uri) {
_uiState.update { it.copy(newTicketAttachments = it.newTicketAttachments - uri) }
}
fun onChatInputChange(text: String) {
_uiState.update { it.copy(chatInputMessage = text) }
}
fun onChatImagesSelected(uris: List<Uri>) {
val current = _uiState.value.chatInputAttachments
if (current.size + uris.size > 5) {
_uiState.update { it.copy(errorMessage = "Max 5 images allowed per message.") }
return
}
validateAndAddImages(uris) { validUris ->
_uiState.update { it.copy(chatInputAttachments = it.chatInputAttachments + validUris) }
}
}
fun onRemoveChatImage(uri: Uri) {
_uiState.update { it.copy(chatInputAttachments = it.chatInputAttachments - uri) }
}
private fun validateAndAddImages(uris: List<Uri>, onValid: (List<Uri>) -> Unit) {
val context = getApplication<Application>()
val maxFileSize = 5 * 1024 * 1024 // 5 MB
val validUris = mutableListOf<Uri>()
for (uri in uris) {
val fileSize = getFileSize(context, uri)
if (fileSize > maxFileSize) {
_uiState.update { it.copy(errorMessage = "One or more images exceed the 5MB limit.") }
return
}
validUris.add(uri)
}
onValid(validUris)
}
private fun getFileSize(context: Context, uri: Uri): Long {
return try {
context.contentResolver.openFileDescriptor(uri, "r")?.use { it.statSize } ?: 0L
} catch (_: Exception) { 0L }
}
fun onSubmitTicket() {
val state = _uiState.value
if (state.newTicketMessage.isBlank()) return
val user = currentUser ?: return
onCancelCreateTicket()
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
try {
val threadId = repository.createThread(
userId = user.uid,
category = state.newTicketCategory,
message = state.newTicketMessage,
attachmentUris = state.newTicketAttachments
)
onThreadSelected(threadId)
} catch (e: Exception) {
Timber.e(e, "ViewModel: Error submitting ticket")
_uiState.update { it.copy(errorMessage = "Failed to create ticket: ${e.message}") }
} finally {
_uiState.update { it.copy(isLoading = false) }
}
}
}
fun onSendMessage() {
val state = _uiState.value
val threadId = state.selectedThreadId ?: return
val textToSend = state.chatInputMessage.trim()
val attachmentsToSend = state.chatInputAttachments
if (textToSend.isBlank() && attachmentsToSend.isEmpty()) return
val user = currentUser ?: return
val messageId = repository.generateMessageId()
Timber.d("VM: Sending message. Generated ID: $messageId")
// Updated reference to id
val pendingMessage = FeedbackMessage(
id = messageId,
text = textToSend,
sender = "user",
timestamp = Date(),
attachments = attachmentsToSend.map { it.toString() }
)
_uiState.update {
it.copy(
chatInputMessage = "",
chatInputAttachments = emptyList(),
pendingMessages = it.pendingMessages + pendingMessage
)
}
viewModelScope.launch {
try {
repository.addMessage(
threadId = threadId,
messageId = messageId,
uid = user.uid,
message = textToSend,
sender = "user",
attachmentUris = attachmentsToSend
)
Timber.d("VM: addMessage returned success for $messageId")
} catch (e: Exception) {
Timber.e(e, "ViewModel: Error sending message")
_uiState.update {
it.copy(
errorMessage = "Failed to send: ${e.message}",
// Updated reference to id
pendingMessages = it.pendingMessages.filterNot { msg -> msg.id == messageId },
chatInputMessage = textToSend
)
}
}
}
}
fun clearError() {
_uiState.update { it.copy(errorMessage = null) }
}
override fun onCleared() {
super.onCleared()
repository.removeListener(messagesListener)
repository.removeListener(threadsListener)
}
}

View file

@ -0,0 +1,67 @@
package com.aryan.reader.feedback
import android.content.Context
import android.os.Build
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import com.aryan.reader.BuildConfig
import com.aryan.reader.data.FeedbackRepository
import com.aryan.reader.data.FeedbackTextPayload
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.core.net.toUri
import com.aryan.reader.AuthRepository
class FeedbackWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
private val feedbackRepository = FeedbackRepository(applicationContext)
private val authRepository = AuthRepository(applicationContext)
override suspend fun doWork(): Result {
val message = inputData.getString(KEY_MESSAGE) ?: return Result.failure()
val category = inputData.getString(KEY_CATEGORY) ?: return Result.failure()
val imageUris = inputData.getStringArray(KEY_IMAGE_URIS)?.map { it.toUri() } ?: emptyList()
val userId = authRepository.getSignedInUser()?.uid ?: "Not signed in"
val contextMap = mapOf(
"appVersion" to BuildConfig.VERSION_NAME,
"deviceModel" to "${Build.MANUFACTURER} ${Build.MODEL}",
"androidVersion" to Build.VERSION.SDK_INT.toString(),
"userId" to userId
)
val payload = FeedbackTextPayload(message, category, contextMap)
return withContext(Dispatchers.IO) {
val textResult = feedbackRepository.sendFeedbackText(payload)
val response = textResult.getOrNull()
if (textResult.isFailure || response?.status != "success" || response.thread_ts == null) {
return@withContext if (runAttemptCount < 3) Result.retry() else Result.failure()
}
val threadTs = response.thread_ts
imageUris.forEach { uri ->
val imageResult = feedbackRepository.sendFeedbackImage(uri, threadTs)
if (imageResult.isFailure) {
return@withContext Result.failure(workDataOf("error" to "Image upload failed"))
}
}
Result.success()
}
}
companion object {
const val KEY_MESSAGE = "KEY_MESSAGE"
const val KEY_CATEGORY = "KEY_CATEGORY"
const val KEY_IMAGE_URIS = "KEY_IMAGE_URIS"
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,493 @@
// ContentStyler.kt
package com.aryan.reader.paginatedreader
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.isUnspecified
import androidx.compose.ui.unit.sp
import org.jsoup.Jsoup
import java.io.File
import java.net.URLDecoder
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class ContentStyler(
private val baseTextStyle: TextStyle,
private val fontFamilyMap: Map<String, FontFamily>,
private val density: Density,
private val isDarkTheme: Boolean,
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val userTextAlign: TextAlign?
) {
fun style(semanticBlocks: List<SemanticBlock>): List<ContentBlock> {
return groupFloatingBlocks(semanticBlocks.mapNotNull { styleBlock(it) })
}
// ADD this function to group floating blocks, similar to the original parser
private fun groupFloatingBlocks(blocks: List<ContentBlock>): List<ContentBlock> {
if (blocks.isEmpty()) return emptyList()
val result = mutableListOf<ContentBlock>()
val processingQueue = blocks.toMutableList()
while (processingQueue.isNotEmpty()) {
val currentBlock = processingQueue.removeAt(0)
val floatDirection = (currentBlock as? ImageBlock)?.style?.float
if (currentBlock is ImageBlock && floatDirection in listOf("left", "right")) {
val floatedImage = currentBlock
val paragraphsToWrap = mutableListOf<ParagraphBlock>()
while (processingQueue.isNotEmpty()) {
val nextBlock = processingQueue.first()
val nextBlockStyle = nextBlock.style
val shouldClear = nextBlockStyle.clear in listOf("both", floatDirection)
if (nextBlock is ParagraphBlock && !shouldClear) {
val paragraph = processingQueue.removeAt(0) as ParagraphBlock
paragraphsToWrap.add(paragraph)
} else {
break
}
}
val wrappingBlock = WrappingContentBlock(
floatedImage,
paragraphsToWrap,
elementId = floatedImage.elementId,
cfi = floatedImage.cfi,
blockIndex = floatedImage.blockIndex
)
result.add(wrappingBlock)
} else {
result.add(currentBlock)
}
}
return result
}
private fun styleBlock(block: SemanticBlock): ContentBlock? {
val themedStyle = applyThemeToStyle(block.style)
return when (block) {
is SemanticParagraph -> {
val computedTextAlign = userTextAlign ?: themedStyle.paragraphStyle.textAlign
ParagraphBlock(
content = buildAnnotatedString(block, themedStyle),
textAlign = computedTextAlign,
style = themedStyle.blockStyle,
elementId = block.elementId,
cfi = block.cfi,
startCharOffsetInSource = block.startCharOffsetInSource,
blockIndex = block.blockIndex
)
}
is SemanticHeader -> HeaderBlock(
level = block.level,
content = buildAnnotatedString(block, themedStyle),
textAlign = themedStyle.paragraphStyle.textAlign,
style = themedStyle.blockStyle,
elementId = block.elementId,
cfi = block.cfi,
startCharOffsetInSource = block.startCharOffsetInSource,
blockIndex = block.blockIndex
)
is SemanticImage -> {
var finalBlockStyle = themedStyle.blockStyle
if (themedStyle.paragraphStyle.textAlign == TextAlign.Center) {
finalBlockStyle = finalBlockStyle.copy(horizontalAlign = "center")
}
val shouldInvert = themedStyle.blockStyle.filter == "invert(100%)"
ImageBlock(
path = block.path,
altText = block.altText,
intrinsicWidth = block.intrinsicWidth,
intrinsicHeight = block.intrinsicHeight,
style = finalBlockStyle,
elementId = block.elementId,
cfi = block.cfi,
invertOnDarkTheme = shouldInvert,
blockIndex = block.blockIndex
)
}
is SemanticMath -> {
val finalSvgContent = when {
block.isFromMathJax || block.svgContent.isNullOrBlank() -> block.svgContent
else -> {
val themedSvg = applyThemeToSvg(block.svgContent)
embedImagesInSvg(themedSvg)
}
}
MathBlock(
svgContent = finalSvgContent,
altText = block.altText,
style = themedStyle.blockStyle,
elementId = block.elementId,
cfi = block.cfi,
svgWidth = block.svgWidth,
svgHeight = block.svgHeight,
svgViewBox = block.svgViewBox,
isFromMathJax = block.isFromMathJax,
blockIndex = block.blockIndex
)
}
is SemanticList -> styleList(block, themedStyle)
is SemanticTable -> styleTable(block, themedStyle)
is SemanticSpacer -> {
val height = if (block.isExplicitLineBreak) with(density) { baseTextStyle.fontSize.toDp() } else 8.dp
SpacerBlock(height = height, style = themedStyle.blockStyle, elementId = block.elementId, cfi = block.cfi, blockIndex = block.blockIndex)
}
is SemanticFlexContainer -> FlexContainerBlock(
children = block.children.mapNotNull { styleBlock(it) },
style = themedStyle.blockStyle,
elementId = block.elementId,
cfi = block.cfi,
blockIndex = block.blockIndex
)
is SemanticWrappingBlock -> {
val styledImage = styleBlock(block.floatedImage) as? ImageBlock
val styledParagraphs = block.paragraphsToWrap.mapNotNull { styleBlock(it) as? ParagraphBlock }
if (styledImage != null) {
WrappingContentBlock(
floatedImage = styledImage,
paragraphsToWrap = styledParagraphs,
elementId = block.elementId,
cfi = block.cfi,
blockIndex = block.blockIndex
)
} else {
null
}
}
else -> {
Timber.w("Unsupported or misplaced SemanticBlock type encountered: ${block::class.java.simpleName}")
null
}
}
}
private fun applyThemeToStyle(style: CssStyle): CssStyle {
val newSpanStyle = style.spanStyle.let { original ->
val newColor = if (original.color.isSpecified) {
CssParser.adaptColorForTheme(original.color, isDarkTheme, isBackground = false)
} else {
original.color
}
original.copy(color = newColor)
}
val newBlockStyle = style.blockStyle.let { original ->
val newBgColor = if (original.backgroundColor.isSpecified) {
CssParser.adaptColorForTheme(original.backgroundColor, isDarkTheme, isBackground = true)
} else {
original.backgroundColor
}
val newBorder = original.border?.let {
val newBorderColor = CssParser.adaptColorForTheme(it.color, isDarkTheme, isBackground = false)
it.copy(color = newBorderColor)
}
original.copy(backgroundColor = newBgColor, border = newBorder)
}
return style.copy(spanStyle = newSpanStyle, blockStyle = newBlockStyle)
}
private fun embedImagesInSvg(svgContent: String): String {
try {
val svgDocument = Jsoup.parseBodyFragment(svgContent)
val svgElement = svgDocument.body().children().firstOrNull() ?: return svgContent
svgElement.select("image").forEach { imageElement ->
val href = imageElement.attr("href").ifBlank { imageElement.attr("xlink:href") }
if (href.isNotBlank() && !href.startsWith("data:")) {
resolveImagePath(href)?.let { imageFile ->
try {
val imageBytes = imageFile.readBytes()
val mimeType = when (imageFile.extension.lowercase()) {
"jpg", "jpeg" -> "image/jpeg"
"png" -> "image/png"
"gif" -> "image/gif"
"webp" -> "image/webp"
else -> "application/octet-stream"
}
val base64 = android.util.Base64.encodeToString(imageBytes, android.util.Base64.NO_WRAP)
val dataUri = "data:$mimeType;base64,$base64"
imageElement.attr("xlink:href", dataUri)
imageElement.removeAttr("href")
} catch (e: Exception) {
Timber.e(e, "Failed to read and encode image file '$href' to Base64.")
}
}
}
}
return svgElement.outerHtml()
} catch (e: Exception) {
Timber.e(e, "Error while embedding images in SVG content.")
return svgContent
}
}
private fun resolveImagePath(src: String): File? {
if (src.isBlank()) return null
val decodedSrc = try { URLDecoder.decode(src, "UTF-8") } catch (_: Exception) { src }
val parentPath = File(this.chapterAbsPath).parent ?: ""
val fromRelativeFile = File(this.extractionBasePath, File(parentPath, decodedSrc).path)
if (fromRelativeFile.exists()) return fromRelativeFile
val fromRootFile = File(this.extractionBasePath, decodedSrc)
if (fromRootFile.exists()) return fromRootFile
Timber.w("Image not found for SVG embedding. Tried: ${fromRelativeFile.absolutePath} and ${fromRootFile.absolutePath}")
return null
}
private fun applyThemeToSvg(svgContent: String): String {
if (svgContent.isBlank()) return svgContent
try {
val textColorHex = baseTextStyle.color.toCssHexString()
val svgDocument = Jsoup.parseBodyFragment(svgContent)
val svgElement = svgDocument.body().children().firstOrNull() ?: return svgContent
svgElement.select("text").forEach { textElement ->
val existingStyle = textElement.attr("style")
val styleWithoutFill = existingStyle.replace(Regex("""\bfill\s*:\s*[^;]+;?"""), "")
val newStyle = "fill:$textColorHex; $styleWithoutFill".trim()
textElement.attr("style", newStyle)
textElement.removeAttr("fill")
}
return svgElement.outerHtml()
} catch (e: Exception) {
Timber.e(e, "Failed to apply dark theme to SVG content.")
return svgContent
}
}
private fun Color.toCssHexString(): String {
val red = (this.red * 255).toInt()
val green = (this.green * 255).toInt()
val blue = (this.blue * 255).toInt()
return String.format("#%02X%02X%02X", red, green, blue)
}
private fun buildAnnotatedString(
block: SemanticTextBlock,
blockStyle: CssStyle
): AnnotatedString {
Timber.d("ContentStyler: Building annotated string. UserAlign=$userTextAlign, CSSAlign=${blockStyle.paragraphStyle.textAlign}")
val builtString = buildAnnotatedString {
val rootFontFamily = findFirstAvailableFontFamily(blockStyle.fontFamilies, fontFamilyMap)
val hyphensValue = if (blockStyle.hyphens == "auto") Hyphens.Auto else Hyphens.None
val mergedParagraphStyle = baseTextStyle.toParagraphStyle().merge(blockStyle.paragraphStyle)
val finalTextAlign = if (block is SemanticParagraph && userTextAlign != null) {
userTextAlign
} else if (mergedParagraphStyle.textAlign == TextAlign.Justify) {
TextAlign.Left
} else {
mergedParagraphStyle.textAlign
}
val isParagraph = block is SemanticParagraph
val finalLineHeight = if (isParagraph && baseTextStyle.lineHeight.isSpecified) {
baseTextStyle.lineHeight
} else {
mergedParagraphStyle.lineHeight
}
val finalParagraphStyle = ParagraphStyle(
textAlign = finalTextAlign,
textDirection = mergedParagraphStyle.textDirection,
lineHeight = finalLineHeight,
textIndent = mergedParagraphStyle.textIndent,
platformStyle = mergedParagraphStyle.platformStyle,
lineHeightStyle = mergedParagraphStyle.lineHeightStyle,
lineBreak = LineBreak.Paragraph,
hyphens = hyphensValue,
textMotion = mergedParagraphStyle.textMotion
)
var initialSpanStyle = baseTextStyle.toSpanStyle()
.merge(blockStyle.spanStyle)
.copy(fontFamily = baseTextStyle.fontFamily)
if (rootFontFamily == FontFamily.Monospace) {
initialSpanStyle = initialSpanStyle.copy(fontFamily = rootFontFamily)
}
Timber.d("ContentStyler: InitialSpanStyle. BaseFontSize=${baseTextStyle.fontSize}, BlockFontSize=${blockStyle.spanStyle.fontSize} -> Merged=${initialSpanStyle.fontSize}")
withStyle(finalParagraphStyle) {
withStyle(initialSpanStyle) {
append(block.text)
block.spans.sortedBy { it.start }.forEach { span ->
val themedSpanStyle = applyThemeToStyle(span.style)
val fontFamily = findFirstAvailableFontFamily(themedSpanStyle.fontFamilies, fontFamilyMap)
val baselineShift = when (span.tag) {
"sub" -> BaselineShift.Subscript
"sup" -> BaselineShift.Superscript
else -> null
}
val finalSpanStyle = themedSpanStyle.spanStyle.copy(
fontFamily = fontFamily,
baselineShift = baselineShift
)
addStyle(initialSpanStyle.merge(finalSpanStyle), span.start, span.end)
if (span.linkHref != null) {
addStringAnnotation("URL", span.linkHref, span.start, span.end)
}
if (span.elementId != null) {
addStringAnnotation("ID", span.elementId, span.start, span.end)
}
}
}
}
}
return builtString.maybeAdjustLineHeightForEmphasis()
}
private fun AnnotatedString.maybeAdjustLineHeightForEmphasis(): AnnotatedString {
if (this.getStringAnnotations("TextEmphasis", 0, this.length).isNotEmpty()) {
val currentParagraphStyle = this.paragraphStyles.firstOrNull()?.item ?: ParagraphStyle()
val currentLineHeight = currentParagraphStyle.lineHeight
val newLineHeight = if (currentLineHeight.isUnspecified || currentLineHeight.value == 0f) {
1.8.em
} else if (currentLineHeight.isEm) {
(currentLineHeight.value * 1.3f).em
} else if (currentLineHeight.isSp) {
(currentLineHeight.value * 1.3f).sp
} else {
1.8.em
}
return buildAnnotatedString {
withStyle(ParagraphStyle(lineHeight = newLineHeight)) {
append(this@maybeAdjustLineHeightForEmphasis)
}
}
}
return this
}
private fun styleList(list: SemanticList, listStyle: CssStyle): ContentBlock {
var itemCounter = 1
val items = list.items.map { item ->
val itemThemedStyle = applyThemeToStyle(item.style)
val mergedBlockStyle = listStyle.blockStyle.merge(itemThemedStyle.blockStyle)
val marker = getListMarker(
listStyleType = mergedBlockStyle.listStyleType,
counter = itemCounter,
isOrdered = list.isOrdered
)
itemCounter++
ListItemBlock(
content = buildAnnotatedString(item, itemThemedStyle),
itemMarker = marker,
itemMarkerImage = item.itemMarkerImage,
style = mergedBlockStyle,
elementId = item.elementId,
cfi = item.cfi,
startCharOffsetInSource = item.startCharOffsetInSource,
blockIndex = item.blockIndex
)
}
return FlexContainerBlock(items, listStyle.blockStyle, list.elementId, list.cfi, list.blockIndex)
}
private fun styleTable(table: SemanticTable, tableStyle: CssStyle): TableBlock {
val rows = table.rows.map { row ->
row.map { cell ->
val cellCssStyle = applyThemeToStyle(cell.style)
TableCell(
content = cell.content.mapNotNull { styleBlock(it) },
isHeader = cell.isHeader,
style = cellCssStyle,
colspan = cell.colspan
)
}
}
return TableBlock(
rows = rows,
style = tableStyle.blockStyle,
elementId = table.elementId,
cfi = table.cfi,
blockIndex = table.blockIndex
)
}
private fun findFirstAvailableFontFamily(
fontFamilyNames: List<String>,
fontFamilyMap: Map<String, FontFamily>
): FontFamily? {
if (fontFamilyNames.isEmpty()) return null
val specificFont = fontFamilyNames.firstNotNullOfOrNull { fontFamilyMap[it] }
if (specificFont != null) return specificFont
return fontFamilyNames.firstNotNullOfOrNull { name -> FontFamilyMapper.nameToFontFamily(name) }
}
private fun toRoman(number: Int): String {
if (number < 1 || number > 3999) return number.toString()
val values = listOf(1000, 900, 500, 400, 100, 90, 50, 40, 10, 9, 5, 4, 1)
val symbols = listOf("M", "CM", "D", "CD", "C", "XC", "L", "XL", "X", "IX", "V", "IV", "I")
val result = StringBuilder()
var num = number
for (i in values.indices) {
while (num >= values[i]) {
num -= values[i]
result.append(symbols[i])
}
}
return result.toString()
}
private fun toAlpha(number: Int): String {
if (number < 1) return number.toString()
var n = number
val result = StringBuilder()
while (n > 0) {
n--
result.insert(0, ('a' + n % 26))
n /= 26
}
return result.toString()
}
private fun getListMarker(listStyleType: String?, counter: Int, isOrdered: Boolean): String? {
val finalType = listStyleType?.trim()?.lowercase() ?: if (isOrdered) "decimal" else "disc"
return when (finalType) {
"none" -> null
"disc" -> ""
"circle" -> ""
"square" -> ""
"decimal" -> "$counter. "
"decimal-leading-zero" -> "${counter.toString().padStart(2, '0')}. "
"lower-roman" -> toRoman(counter).lowercase() + ". "
"upper-roman" -> toRoman(counter).uppercase() + ". "
"lower-latin", "lower-alpha" -> toAlpha(counter) + ". "
"upper-latin", "upper-alpha" -> toAlpha(counter).uppercase() + ". "
else -> if (isOrdered) "$counter. " else ""
}
}
}

View file

@ -0,0 +1,859 @@
// CssParser.kt
package com.aryan.reader.paginatedreader
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import java.io.File
import java.util.regex.Pattern
import kotlin.math.roundToInt
private const val IMPORTANT_SPECIFICITY_BOOST = 10_000
private fun Color.luminance(): Float {
if (!this.isSpecified) return 0f
return (0.299f * red + 0.587f * green + 0.114f * blue)
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
object CssParser {
private val FONT_FACE_REGEX = "@font-face\\s*\\{([^}]+)\\}".toRegex(RegexOption.DOT_MATCHES_ALL)
private val URL_REGEX = "url\\((['\"]?)(.*?)\\1\\)".toRegex()
private val ID_SELECTOR_PATTERN = Pattern.compile("#[^\\s,]+")
private val CLASS_ATTRIBUTE_SELECTOR_PATTERN = Pattern.compile("\\.[^\\s,]+|\\[[^]]+]|:(?!:)[^\\s,]+")
private val TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN = Pattern.compile("(?<![.#\\[])\\b[a-zA-Z-]+|::[a-zA-Z-]+")
private data class FontSource(val url: String, val format: String?)
// Regex to identify simple, single-part selectors for fast categorization
private val SIMPLE_TAG_SELECTOR = Regex("^[a-zA-Z0-9]+$")
private val SIMPLE_CLASS_SELECTOR = Regex("^\\.[a-zA-Z0-9_-]+$")
private val SIMPLE_ID_SELECTOR = Regex("^#[a-zA-Z0-9_-]+$")
private val BORDER_WIDTH_KEYWORDS = mapOf(
"thin" to 1.dp,
"medium" to 3.dp,
"thick" to 5.dp
)
internal fun adaptColorForTheme(color: Color, isDarkTheme: Boolean, isBackground: Boolean): Color {
if (!color.isSpecified) return color
if (color.alpha < 0.9f) return color
val luminance = color.luminance()
return if (isDarkTheme) {
if (isBackground) {
if (luminance > 0.9) Color.Transparent else color
} else {
if (luminance < 0.2) Color.White.copy(alpha = 0.87f) else color
}
} else {
if (isBackground) {
if (luminance < 0.1) Color.Transparent else color
} else {
if (luminance > 0.8) Color.Black.copy(alpha = 0.87f) else color
}
}
}
private fun splitDeclarations(declarations: String): List<String> {
val parts = declarations.split(';').toMutableList()
if (parts.size <= 1) return parts
val result = mutableListOf<String>()
val iterator = parts.listIterator()
while(iterator.hasNext()) {
var current = iterator.next()
val originalCurrent = current
var reassembled = false
while (current.count { it == '(' } > current.count { it == ')' }) {
if (!iterator.hasNext()) break
val nextPart = iterator.next()
current += ";$nextPart"
reassembled = true
}
if (reassembled) {
Timber.d("Reassembled declaration. Original: '$originalCurrent'. Final: '$current'")
}
result.add(current)
}
return result
}
private fun calculateSpecificity(selector: String): Int {
val ids = ID_SELECTOR_PATTERN.matcher(selector).run {
var count = 0
while (find()) count++
count
}
val classesAndAttributes = CLASS_ATTRIBUTE_SELECTOR_PATTERN.matcher(selector).run {
var count = 0
while (find()) count++
count
}
val elementsAndPseudos = TYPE_PSEUDO_ELEMENT_SELECTOR_PATTERN.matcher(selector).run {
var count = 0
while (find()) count++
count
}
val specificity = ids * 100 + classesAndAttributes * 10 + elementsAndPseudos
return specificity
}
fun parse(
cssContent: String,
cssPath: String?,
baseFontSizeSp: Float,
density: Float,
constraints: Constraints,
isDarkTheme: Boolean
): OptimizedCssParseResult {
val byTag = mutableMapOf<String, MutableList<CssRule>>()
val byClass = mutableMapOf<String, MutableList<CssRule>>()
val byId = mutableMapOf<String, MutableList<CssRule>>()
val otherComplex = mutableListOf<CssRule>()
val fontFaces = mutableListOf<FontFaceInfo>()
val blockRegex = "([^{}]+)\\s*\\{([^}]+)\\}".toRegex()
var cleanedCss = cssContent.replace(Regex("/\\*.*?\\*/", RegexOption.DOT_MATCHES_ALL), "")
val mediaQueryRegex = Regex("@media[^{]+\\{((?>[^{}]+|\\{[^{}]*\\})*)\\}")
mediaQueryRegex.findAll(cleanedCss).forEach { match ->
val condition = match.groups[0]?.value?.trim() ?: ""
if (isDarkTheme && condition.contains("prefers-color-scheme: dark")) {
val darkCss = match.groups[1]?.value ?: ""
cleanedCss += "\n$darkCss"
}
}
cleanedCss = mediaQueryRegex.replace(cleanedCss, "")
Timber.d("CssParser: Checking for @font-face rules...")
val fontFaceMatches = FONT_FACE_REGEX.findAll(cleanedCss)
if (!fontFaceMatches.any()) {
Timber.d("CssParser: No @font-face rules found by regex.")
}
fontFaceMatches.forEach { match ->
Timber.d("CssParser: Found a @font-face block. Parsing its properties.")
val properties = match.groupValues[1]
parseFontFace(properties, cssPath)?.let { fontFaces.add(it) }
}
cleanedCss = FONT_FACE_REGEX.replace(cleanedCss, "")
blockRegex.findAll(cleanedCss).forEach { matchResult ->
val selectorGroup = matchResult.groups[1]?.value?.trim() ?: ""
val propertiesGroup = matchResult.groups[2]?.value?.trim() ?: ""
val selectors = selectorGroup.split(',').map { it.trim() }
for (originalSelector in selectors) {
if (originalSelector.isBlank() || originalSelector.startsWith("@")) {
continue
}
val sanitizedSelector = originalSelector.replace(
Regex(":(link|visited|hover|active|focus)\\b|::(first-letter|first-line|marker)\\b", RegexOption.IGNORE_CASE),
""
).trim()
if (sanitizedSelector.isBlank()) {
continue
}
val specificity = calculateSpecificity(originalSelector)
val normalStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = false,
isDarkTheme
)
val importantStyle = parseProperties(
propertiesGroup, baseFontSizeSp, density, constraints, onlyImportant = true,
isDarkTheme
)
fun addRule(style: CssStyle, spec: Int) {
if (style == CssStyle()) return
val rule = CssRule(CssSelector(sanitizedSelector, spec), style)
when {
SIMPLE_ID_SELECTOR.matches(sanitizedSelector) ->
byId.getOrPut(sanitizedSelector.substring(1)) { mutableListOf() }.add(rule)
SIMPLE_CLASS_SELECTOR.matches(sanitizedSelector) ->
byClass.getOrPut(sanitizedSelector.substring(1)) { mutableListOf() }.add(rule)
SIMPLE_TAG_SELECTOR.matches(sanitizedSelector) ->
byTag.getOrPut(sanitizedSelector) { mutableListOf() }.add(rule)
else -> otherComplex.add(rule)
}
}
addRule(normalStyle, specificity)
addRule(importantStyle, specificity + IMPORTANT_SPECIFICITY_BOOST)
}
}
val optimizedRules = OptimizedCssRules(byTag, byClass, byId, otherComplex)
return OptimizedCssParseResult(optimizedRules, fontFaces)
}
private fun parseFontFace(properties: String, cssPath: String?): FontFaceInfo? {
val propsMap = splitDeclarations(properties)
.map { it.trim().split(':', limit = 2).map { part -> part.trim() } }
.filter { it.size == 2 && it[0].isNotBlank() }
.associate { it[0].lowercase() to it[1] }
val fontFamily = propsMap["font-family"]?.removeSurrounding("\"")?.removeSurrounding("'")?.lowercase()
val srcString = propsMap["src"]
Timber.d("Parsing font-face for family: $fontFamily. Raw src string: $srcString")
if (fontFamily == null || srcString == null) {
Timber.w("Incomplete @font-face rule: missing font-family or src.")
return null
}
val urlWithFormatRegex = "url\\((['\"]?)(.*?)\\1\\)\\s*format\\((['\"]?)(.*?)\\3\\)".toRegex()
val sources = srcString.split(Regex(",(?=\\s*url\\()")).mapNotNull { part ->
val trimmedPart = part.trim()
Timber.d("Processing src part: '$trimmedPart'")
urlWithFormatRegex.find(trimmedPart)?.let {
Timber.d("Matched url with format(). URL: ${it.groupValues[2]}, Format: ${it.groupValues[4]}")
FontSource(url = it.groupValues[2], format = it.groupValues[4].lowercase().removeSurrounding("'"))
} ?: URL_REGEX.find(trimmedPart)?.let {
val url = it.groupValues[2]
Timber.d("Matched url() only. URL: '$url'")
val format = when {
url.startsWith("data:", ignoreCase = true) -> {
val mediaType = url.substringAfter("data:").substringBefore(';')
Timber.d("Data URI detected. Media type: '$mediaType'")
when {
mediaType.contains("opentype") -> "opentype"
mediaType.contains("truetype") -> "truetype"
mediaType.contains("woff2") -> "woff2"
mediaType.contains("woff") -> "woff"
else -> {
Timber.w("Unknown data URI media type: $mediaType")
null
}
}
}
url.endsWith(".woff2", ignoreCase = true) -> "woff2"
url.endsWith(".woff", ignoreCase = true) -> "woff"
url.endsWith(".otf", ignoreCase = true) -> "opentype"
url.endsWith(".ttf", ignoreCase = true) -> "truetype"
else -> {
Timber.w("Could not determine format from URL: $url")
null
}
}
Timber.d("Determined format: '$format'")
if (format != null) {
FontSource(url = url, format = format)
} else {
null
}
}
}
if (sources.isEmpty()) {
Timber.w("Could not parse any valid source from @font-face src: $srcString")
return null
}
val preferredSource = sources.minByOrNull {
when (it.format) {
"opentype", "otf" -> 1
"truetype", "ttf" -> 2
"woff2" -> 3
"woff" -> 4
else -> 5
}
}!!
val rawSrc = preferredSource.url
Timber.d("Selected font source for '$fontFamily': '${preferredSource.url}' with format '${preferredSource.format}'")
val finalSrc = if (cssPath != null && !rawSrc.startsWith("data:")) {
try {
val cssParentDir = File(cssPath).parent ?: ""
File(cssParentDir, rawSrc).normalize().path
} catch (e: Exception) {
Timber.e(e, "Could not resolve font path for src '$rawSrc' in css '$cssPath'")
rawSrc // Fallback to the raw path on error
}
} else {
rawSrc
}
val fontWeight = when (propsMap["font-weight"]) {
"bold" -> FontWeight.Bold
"700" -> FontWeight.Bold
"600" -> FontWeight.SemiBold
"500" -> FontWeight.Medium
"300" -> FontWeight.Light
"200" -> FontWeight.ExtraLight
"100" -> FontWeight.Thin
else -> FontWeight.Normal
}
val fontStyle = when (propsMap["font-style"]) {
"italic", "oblique" -> FontStyle.Italic
else -> FontStyle.Normal
}
return FontFaceInfo(fontFamily, finalSrc, fontWeight, fontStyle)
}
internal fun parseProperties(
properties: String,
baseFontSizeSp: Float,
density: Float,
constraints: Constraints,
onlyImportant: Boolean,
isDarkTheme: Boolean
): CssStyle {
var spanStyle = SpanStyle()
var paragraphStyle = ParagraphStyle()
var padding = BoxBorders()
var width: Dp = Dp.Unspecified
var maxWidth: Dp = Dp.Unspecified
var height: Dp = Dp.Unspecified
var backgroundColor: Color = Color.Unspecified
// Changed: Track the max width found to prioritize visible borders
var maxBorderWidthFound: Dp = 0.dp
var finalBorderColor: Color? = null
var finalBorderStyle: String? = null
var fontFamilies: List<String> = emptyList()
var fontSize: TextUnit = TextUnit.Unspecified
var pageBreakInsideAvoid = false
var listStyleType: String? = null
var listStyleImage: String? = null
var display: String? = null
val containerWidthPx = constraints.maxWidth
var pageBreakAfterAvoid = false
var textTransform: String? = null
var boxSizing: String? = null
var float: String? = null
var clear: String? = null
var content: String? = null
var position: String? = null
var left: Dp = Dp.Unspecified
var top: Dp = Dp.Unspecified
var right: Dp = Dp.Unspecified
var bottom: Dp = Dp.Unspecified
var flexDirection: String? = null
var justifyContent: String? = null
var alignItems: String? = null
var filter: String? = null
var borderCollapse: String? = null
var borderSpacing: Dp = 0.dp
var borderRadius: Dp = 0.dp
var hyphens: String? = null
var fontVariantNumeric: String? = null
var textEmphasisStyleString: String? = null
var textEmphasisColor: Color? = null
var textEmphasisPositionString: String? = null
var marginTopStr: String? = null
var marginRightStr: String? = null
var marginBottomStr: String? = null
var marginLeftStr: String? = null
splitDeclarations(properties).filter { it.isNotBlank() }.forEach { prop ->
val parts = prop.split(':', limit = 2).map { it.trim() }
if (parts.size == 2) {
val key = parts[0].lowercase()
val valueWithImportant = parts[1]
val isImportant = valueWithImportant.contains("!important", ignoreCase = true)
if (isImportant != onlyImportant) {
return@forEach
}
val value = if (isImportant) {
valueWithImportant.replace(Regex("\\s*!important", RegexOption.IGNORE_CASE), "").trim()
} else {
valueWithImportant
}
// Helper to update border props ONLY if this border is significant
fun updateUnifiedBorder(
widthStr: String?,
colorStr: String?,
styleStr: String?
) {
val parsedWidth = widthStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
val parsedColor = colorStr?.let { parseColor(it) }?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false) }
// We update the unified style if:
// 1. We found a width larger than what we've seen (prioritize visible borders)
// 2. Or we haven't seen any width yet and this is the first definition
// 3. Or the specific property is just setting style/color and we want to take the last one defined (standard CSS cascade behavior for same-specificity)
// However, for separate sides (left vs bottom), we strictly prioritize the one with width.
val isExplicitWidth = widthStr != null
if (parsedWidth > maxBorderWidthFound) {
maxBorderWidthFound = parsedWidth
if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr
} else if (parsedWidth == maxBorderWidthFound && maxBorderWidthFound > 0.dp) {
// If equal non-zero width, let last defined win (cascade)
if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr
} else if (!isExplicitWidth) {
// Just updating color or style without width
if (parsedColor != null) finalBorderColor = parsedColor
if (styleStr != null) finalBorderStyle = styleStr
}
}
when (key) {
// ... [Keep existing cases for font-family, font-size, font-weight, font-style, color, text-align, line-height, text-indent, text-decoration, letter-spacing, text-transform, font-variant, margin, margin-*, padding, padding-*, width, max-width, height, background-color] ...
"font-family" -> {
fontFamilies = value.split(',')
.map { it.trim().removeSurrounding("\"").removeSurrounding("'").lowercase() }
}
"font-size" -> {
val trimmedValue = value.trim().lowercase()
fontSize = if (trimmedValue.endsWith("%")) {
val percentage = trimmedValue.removeSuffix("%").toFloatOrNull()
if (percentage != null) {
(percentage / 100f).em
} else {
TextUnit.Unspecified
}
} else {
parseCssDimensionToTextUnit(value, containerWidthPx, density)
}
}
"font-weight" -> {
spanStyle = spanStyle.copy(fontWeight = when (value) {
"bold" -> FontWeight.Bold
"700" -> FontWeight.Bold
"600" -> FontWeight.SemiBold
"500" -> FontWeight.Medium
"300" -> FontWeight.Light
"200" -> FontWeight.ExtraLight
"100" -> FontWeight.Thin
"normal" -> FontWeight.Normal
"400" -> FontWeight.Normal
else -> value.toIntOrNull()?.let { FontWeight(it) } ?: spanStyle.fontWeight
})
}
"font-style" -> {
if (value == "italic" || value == "oblique") spanStyle = spanStyle.copy(fontStyle = FontStyle.Italic)
else if (value == "normal") spanStyle = spanStyle.copy(fontStyle = FontStyle.Normal)
}
"color" -> {
parseColor(value)?.let {
spanStyle = spanStyle.copy(color = this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false))
}
}
"text-align" -> {
val align = when (value) {
"center" -> TextAlign.Center
"right" -> TextAlign.End
"justify" -> TextAlign.Justify
else -> TextAlign.Start
}
paragraphStyle = paragraphStyle.copy(textAlign = align)
}
"line-height" -> {
val trimmedValue = value.trim()
var lineHeight = when {
trimmedValue.endsWith("%") -> {
val percentage = trimmedValue.removeSuffix("%").toFloatOrNull()
if (percentage != null) {
(percentage / 100f).em
} else {
TextUnit.Unspecified
}
}
trimmedValue.toFloatOrNull() != null && trimmedValue.none { it.isLetter() } -> {
trimmedValue.toFloatOrNull()?.em ?: TextUnit.Unspecified
}
else -> parseCssDimensionToTextUnit(trimmedValue, containerWidthPx, density)
}
if (lineHeight.isEm && lineHeight.value < 1.2f && lineHeight.value > 0) {
lineHeight = 2f.em
}
if (lineHeight != TextUnit.Unspecified) {
paragraphStyle = paragraphStyle.copy(lineHeight = lineHeight)
}
}
"text-indent" -> {
val indent = parseCssDimensionToTextUnit(value, containerWidthPx, density)
if (indent != TextUnit.Unspecified) {
paragraphStyle = paragraphStyle.copy(textIndent = TextIndent(firstLine = indent))
}
}
"text-decoration" -> {
spanStyle = spanStyle.copy(
textDecoration = when(value) {
"underline" -> TextDecoration.Underline
"line-through" -> TextDecoration.LineThrough
"none" -> TextDecoration.None
else -> spanStyle.textDecoration
}
)
}
"letter-spacing" -> {
val letterSpacing = parseCssDimensionToTextUnit(value, containerWidthPx, density)
if (letterSpacing != TextUnit.Unspecified) {
spanStyle = spanStyle.copy(letterSpacing = letterSpacing)
}
}
"text-transform" -> {
textTransform = when (value) {
"uppercase", "lowercase", "capitalize", "none" -> value
else -> null
}
}
"font-variant" -> {
if (value.contains("small-caps")) {
spanStyle = spanStyle.copy(fontFeatureSettings = "\"smcp\" on")
}
}
"margin" -> {
val marginParts = value.split(' ').filter { it.isNotBlank() }
when (marginParts.size) {
1 -> {
marginTopStr = marginParts[0]; marginRightStr = marginParts[0]; marginBottomStr = marginParts[0]; marginLeftStr = marginParts[0]
}
2 -> {
marginTopStr = marginParts[0]; marginBottomStr = marginParts[0]
marginRightStr = marginParts[1]; marginLeftStr = marginParts[1]
}
3 -> {
marginTopStr = marginParts[0]
marginRightStr = marginParts[1]; marginLeftStr = marginParts[1]
marginBottomStr = marginParts[2]
}
4 -> {
marginTopStr = marginParts[0]; marginRightStr = marginParts[1]; marginBottomStr = marginParts[2]; marginLeftStr = marginParts[3]
}
}
}
"margin-top" -> marginTopStr = value
"margin-bottom" -> marginBottomStr = value
"margin-left" -> marginLeftStr = value
"margin-right" -> marginRightStr = value
"padding" -> padding = parseBoxBorders(value, baseFontSizeSp, density, containerWidthPx)
"padding-top" -> padding = padding.copy(top = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"padding-bottom" -> padding = padding.copy(bottom = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"padding-left" -> padding = padding.copy(left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"padding-right" -> padding = padding.copy(right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx))
"width" -> width = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"max-width" -> maxWidth = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"height" -> height = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"background-color" -> {
val originalColor = parseColor(value) ?: Color.Unspecified
backgroundColor = this@CssParser.adaptColorForTheme(originalColor, isDarkTheme, isBackground = true)
}
// Border Properties - Logic Updated
"border-width" -> updateUnifiedBorder(value, null, null)
"border-color" -> updateUnifiedBorder(null, value, null)
"border-style" -> updateUnifiedBorder(null, null, value)
"border-top-width", "border-bottom-width", "border-left-width", "border-right-width" -> {
updateUnifiedBorder(value, null, null)
}
"border-top-color", "border-bottom-color", "border-left-color", "border-right-color" -> {
updateUnifiedBorder(null, value, null)
}
"border-top-style", "border-bottom-style", "border-left-style", "border-right-style" -> {
updateUnifiedBorder(null, null, value)
}
"border-bottom", "border-top", "border-left", "border-right", "border" -> {
val borderParts = value.split(" ").filter { it.isNotBlank() }
var widthVal: String? = null
var colorVal: String? = null
var styleVal: String? = null
borderParts.forEach { part ->
val parsedWidth = parseCssSizeToDp(part, baseFontSizeSp, density, containerWidthPx)
if (parsedWidth > 0.dp || part == "0" || part == "0px" || BORDER_WIDTH_KEYWORDS.containsKey(part)) {
widthVal = part
} else if (part in listOf("solid", "dotted", "dashed", "double", "groove", "ridge", "inset", "outset")) {
styleVal = part
} else if (parseColor(part) != null) {
colorVal = part
}
}
updateUnifiedBorder(widthVal, colorVal, styleVal)
}
// End Border Properties
"border-collapse" -> {
if (value in listOf("collapse", "separate")) {
borderCollapse = value
}
}
"border-spacing" -> {
borderSpacing = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
}
"border-radius" -> borderRadius = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
// ... [Keep existing cases for list-style-*, page-break-*, display, flex-*, filter, box-sizing, content, position, top/left/etc, float, hyphens, etc] ...
"list-style-type" -> {
listStyleType = value
}
"list-style-image" -> {
URL_REGEX.find(value)?.groupValues?.get(2)?.let {
listStyleImage = it
}
}
"page-break-inside" -> {
if (value == "avoid") {
pageBreakInsideAvoid = true
}
}
"page-break-after" -> {
if (value == "avoid") {
pageBreakAfterAvoid = true
}
}
"display" -> display = value
"flex-direction" -> flexDirection = value
"justify-content" -> justifyContent = value
"align-items" -> alignItems = value
"filter" -> filter = value
"box-sizing" -> boxSizing = value
"content" -> content = value.removeSurrounding("\"").removeSurrounding("'")
"position" -> position = value
"left" -> left = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"right" -> right = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"top" -> top = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"bottom" -> bottom = parseCssSizeToDp(value, baseFontSizeSp, density, containerWidthPx)
"float" -> {
if (value in listOf("left", "right", "none")) {
float = value
}
}
"hyphens", "-webkit-hyphens", "-moz-hyphens", "-epub-hyphens", "adobe-hyphenate" -> {
if (value in listOf("auto", "manual", "none")) {
hyphens = value
}
}
"font-variant-numeric" -> {
fontVariantNumeric = value
}
"clear" -> {
if (value in listOf("left", "right", "both", "none")) {
clear = value
}
}
"text-emphasis", "-epub-text-emphasis" -> {
textEmphasisStyleString = value
}
"text-emphasis-style", "-epub-text-emphasis-style" -> {
textEmphasisStyleString = value
}
"text-emphasis-color", "-epub-text-emphasis-color" -> {
textEmphasisColor = parseColor(value)?.let { this@CssParser.adaptColorForTheme(it, isDarkTheme, isBackground = false) }
}
"text-emphasis-position", "-epub-text-emphasis-position" -> {
if (value in listOf("over", "under")) {
textEmphasisPositionString = value
}
}
}
}
}
val finalHorizontalAlign = if (marginLeftStr == "auto" && marginRightStr == "auto") "center" else null
val margin = BoxBorders(
top = marginTopStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp,
bottom = marginBottomStr?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp,
left = marginLeftStr.takeIf { it != "auto" }?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp,
right = marginRightStr.takeIf { it != "auto" }?.let { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) } ?: 0.dp
)
// ... [TextEmphasis object creation code remains same] ...
val textEmphasis = if (textEmphasisStyleString != null) {
val parts = textEmphasisStyleString.split(' ').filter { it.isNotBlank() }
var fill: String? = null
var style: String? = null
parts.forEach { part ->
when (part) {
"filled", "open" -> fill = part
"dot", "circle", "double-circle", "triangle", "sesame" -> style = part
else -> {
style = part.removeSurrounding("'").removeSurrounding("\"")
}
}
}
TextEmphasis(
style = style,
fill = fill,
color = textEmphasisColor ?: Color.Unspecified,
position = textEmphasisPositionString
)
} else {
null
}
// Updated: Use the maxBorderWidthFound and corresponding colors
val finalBorder = if (maxBorderWidthFound > 0.dp && finalBorderStyle != null) {
val borderColor = finalBorderColor ?: spanStyle.color.takeIf { it.isSpecified } ?: Color.Black
BorderStyle(
width = maxBorderWidthFound,
color = borderColor,
style = finalBorderStyle
)
} else null
val blockStyle = BlockStyle(
margin = margin, padding = padding, width = width, maxWidth = maxWidth, height = height,
backgroundColor = backgroundColor, border = finalBorder,
listStyleType = listStyleType,
listStyleImage = listStyleImage,
pageBreakInsideAvoid = pageBreakInsideAvoid,
pageBreakAfterAvoid = pageBreakAfterAvoid,
boxSizing = boxSizing,
float = float,
clear = clear,
position = position,
left = left,
right = right,
top = top,
bottom = bottom,
display = display,
flexDirection = flexDirection,
justifyContent = justifyContent,
alignItems = alignItems,
horizontalAlign = finalHorizontalAlign,
filter = filter,
borderCollapse = borderCollapse,
borderSpacing = borderSpacing,
borderRadius = borderRadius
)
return CssStyle(spanStyle, paragraphStyle, blockStyle, fontFamilies, display, fontSize, textTransform, boxSizing, content, hyphens, fontVariantNumeric, textEmphasis)
}
// ADD the parseCssSizeToDp function here at the bottom of the object or file
internal fun parseCssSizeToDp(
size: String,
baseFontSizeSp: Float,
density: Float,
containerWidthPx: Int
): Dp {
val trimmed = size.trim().lowercase()
// Handle keywords
BORDER_WIDTH_KEYWORDS[trimmed]?.let { return it }
if (trimmed == "0" || trimmed == "0px") return 0.dp
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.let { (it / density).dp } ?: 0.dp
trimmed.endsWith("dp") -> trimmed.removeSuffix("dp").toFloatOrNull()?.dp ?: 0.dp
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.let { (it * baseFontSizeSp).dp } ?: 0.dp
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).dp } ?: 0.dp // 1pt ≈ 1.33px
trimmed.endsWith("%") -> {
val percent = trimmed.removeSuffix("%").toFloatOrNull()
if (percent != null) {
((percent / 100f) * containerWidthPx / density).dp
} else {
0.dp
}
}
trimmed.toFloatOrNull() != null -> (trimmed.toFloat() / density).dp
else -> 0.dp
}
}
internal fun parseCssDimensionToTextUnit(
dimension: String?,
containerWidthPx: Int,
density: Float
): TextUnit {
if (dimension.isNullOrBlank()) return TextUnit.Unspecified
val trimmed = dimension.trim().lowercase()
return when {
trimmed.endsWith("px") -> trimmed.removeSuffix("px").toFloatOrNull()?.sp ?: TextUnit.Unspecified
trimmed.endsWith("em") -> trimmed.removeSuffix("em").toFloatOrNull()?.em ?: TextUnit.Unspecified
trimmed.endsWith("rem") -> trimmed.removeSuffix("rem").toFloatOrNull()?.em ?: TextUnit.Unspecified
trimmed.endsWith("%") -> trimmed.removeSuffix("%").toFloatOrNull()?.let { (it / 100f).em } ?: TextUnit.Unspecified
trimmed.endsWith("pt") -> trimmed.removeSuffix("pt").toFloatOrNull()?.let { (it * 1.33f).sp } ?: TextUnit.Unspecified
else -> TextUnit.Unspecified
}
}
internal fun parseColor(colorString: String): Color? {
val sanitized = colorString.trim().lowercase()
return when {
sanitized.startsWith("#") -> {
val hex = sanitized.substring(1)
val colorLong = hex.toLongOrNull(16) ?: return null
when (hex.length) {
3 -> { // #RGB
val r = (colorLong and 0xF00) shr 8
val g = (colorLong and 0x0F0) shr 4
val b = colorLong and 0x00F
Color(Color(0xFF000000 or ((r * 17) shl 16) or ((g * 17) shl 8) or (b * 17)).toArgb())
}
6 -> Color(Color(0xFF000000 or colorLong).toArgb()) // #RRGGBB
8 -> Color(Color(colorLong).toArgb()) // #AARRGGBB
else -> null
}
}
sanitized.startsWith("rgb") -> {
val isRgba = sanitized.startsWith("rgba")
val valuesString = sanitized.substringAfter('(').substringBefore(')')
val values = valuesString.split(',').map { it.trim() }
if (values.size < 3) return null
val r = values[0].toIntOrNull() ?: 0
val g = values[1].toIntOrNull() ?: 0
val b = values[2].toIntOrNull() ?: 0
val a = if (isRgba && values.size == 4) (values[3].toFloatOrNull() ?: 1f) else 1f
Color(r, g, b, (a * 255).roundToInt())
}
else -> when(sanitized) {
"black" -> Color.Black
"white" -> Color.White
"red" -> Color.Red
"green" -> Color.Green
"blue" -> Color.Blue
"gray", "grey" -> Color.Gray
"cyan" -> Color.Cyan
"magenta" -> Color.Magenta
"yellow" -> Color.Yellow
"transparent" -> Color.Transparent
else -> null
}
}
}
private fun parseBoxBorders(value: String, baseFontSizeSp: Float, density: Float, containerWidthPx: Int): BoxBorders {
val parts = value.split(' ').map { it.trim() }.filter { it.isNotEmpty() }
val dps = parts.map { parseCssSizeToDp(it, baseFontSizeSp, density, containerWidthPx) }
return when (dps.size) {
1 -> BoxBorders(top = dps[0], right = dps[0], bottom = dps[0], left = dps[0])
2 -> BoxBorders(top = dps[0], bottom = dps[0], right = dps[1], left = dps[1])
3 -> BoxBorders(top = dps[0], right = dps[1], left = dps[1], bottom = dps[2])
4 -> BoxBorders(top = dps[0], right = dps[1], bottom = dps[2], left = dps[3])
else -> BoxBorders()
}
}
}

View file

@ -0,0 +1,143 @@
// FontLoader.kt
package com.aryan.reader.paginatedreader
import timber.log.Timber
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import java.io.File
import java.security.MessageDigest
/**
* A centralized mapper for handling conversions between generic CSS font family names
* and Compose's FontFamily objects.
*/
object FontFamilyMapper {
private val genericFontMap = mapOf(
"serif" to FontFamily.Serif,
"sans-serif" to FontFamily.SansSerif,
"monospace" to FontFamily.Monospace,
"cursive" to FontFamily.Cursive,
"default" to FontFamily.Default,
"system-ui" to FontFamily.Default,
"ui-sans-serif" to FontFamily.Default,
"ui-serif" to FontFamily.Default,
"ui-monospace" to FontFamily.Default,
"ui-rounded" to FontFamily.Default
)
/**
* Converts a string name (e.g., "serif") to a Compose [FontFamily].
*/
fun nameToFontFamily(name: String): FontFamily? {
return genericFontMap[name.trim().lowercase()]
}
/**
* Converts a Compose [FontFamily] back to its primary string name for serialization.
* Custom fonts are not serialized by name and will return null.
*/
fun fontFamilyToName(fontFamily: FontFamily): String? {
return when (fontFamily) {
FontFamily.Serif -> "serif"
FontFamily.SansSerif -> "sans-serif"
FontFamily.Monospace -> "monospace"
FontFamily.Cursive -> "cursive"
FontFamily.Default -> "default"
else -> null
}
}
}
private fun getCacheKeyForFont(bookId: String, fontPath: String): String {
val identifier = "$bookId:$fontPath"
val digest = MessageDigest.getInstance("MD5").digest(identifier.toByteArray())
return digest.joinToString("") { "%02x".format(it) } + ".ttf"
}
/**
* Loads custom font faces defined in the EPUB's CSS into a map of [FontFamily] objects.
* It handles WOFF2 fonts by converting them to TTF and storing them in a global, persistent cache.
*/
fun loadFontFamilies(fontFaces: List<FontFaceInfo>, extractionPath: String): Map<String, FontFamily> {
if (fontFaces.isEmpty()) {
return emptyMap()
}
Timber.d("Loading ${fontFaces.size} font faces from extraction path: $extractionPath")
// 1. Define a stable, global font cache directory.
// This assumes the parent of the extraction path is a stable base directory for epubs.
val baseCacheDir = File(extractionPath).parentFile ?: return emptyMap()
val fontCacheDir = File(baseCacheDir, "font_cache")
if (!fontCacheDir.exists()) {
fontCacheDir.mkdirs()
}
// 2. Get a stable identifier for the book from the extraction path.
// e.g., "d0e205bf-65cc-4ab4-93cc-cd2d613a7bb3.epub" from a longer temp path.
val bookId = File(extractionPath).name.substringBeforeLast("_")
val fontsByFamily = fontFaces.groupBy {
it.fontFamily.trim().removeSurrounding("'").removeSurrounding("\"").lowercase()
}
Timber.d("Grouped font faces by normalized family: ${fontsByFamily.keys}")
return fontsByFamily.mapValues { (familyName, fontInfos) ->
val fontList = fontInfos.mapNotNull { fontInfo ->
try {
Timber.d("Attempting to load font '$familyName' from resolved src path: '${fontInfo.src}'")
var fontFile = File(extractionPath, fontInfo.src)
if (!fontFile.exists()) {
Timber.w("Font file not found at: ${fontFile.absolutePath}")
return@mapNotNull null
}
// Handle WOFF2 conversion and global caching
if (fontFile.extension.equals("woff2", ignoreCase = true)) {
// 3. Generate a unique, deterministic cache key for the font.
val cacheKey = getCacheKeyForFont(bookId, fontInfo.src)
val cachedTtfFile = File(fontCacheDir, cacheKey)
if (cachedTtfFile.exists()) {
// Use the globally cached TTF file if it exists
fontFile = cachedTtfFile
Timber.d("Using globally cached TTF for '${fontInfo.src}'")
} else {
// Convert and save the TTF to the global cache if it doesn't exist
Timber.d("Converting woff2 font: ${fontFile.name}")
val woff2Data = fontFile.readBytes()
val ttfData = Woff2Converter.convertWoff2ToTtf(woff2Data)
if (ttfData != null) {
cachedTtfFile.writeBytes(ttfData)
fontFile = cachedTtfFile // Use the newly created TTF file
Timber.d("Successfully converted and globally cached woff2 as '${cachedTtfFile.name}'")
} else {
Timber.e("Failed to convert woff2 font: ${fontFile.name}")
return@mapNotNull null
}
}
}
Font(
fontFile,
fontInfo.fontWeight ?: FontWeight.Normal,
fontInfo.fontStyle ?: FontStyle.Normal
)
} catch (e: Exception) {
Timber.e(e, "Error loading font: ${fontInfo.src}")
null
}
}
if (fontList.isNotEmpty()) {
Timber.d("Loaded family '$familyName' with ${fontList.size} font styles.")
FontFamily(fontList)
} else {
Timber.w("Could not load any font styles for family '$familyName'.")
null
}
}.filterValues { it != null }.mapValues { it.value!! }
}

View file

@ -0,0 +1,577 @@
// HtmlParser.kt
package com.aryan.reader.paginatedreader
import android.graphics.BitmapFactory
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
import org.jsoup.select.Selector
import java.io.File
import java.net.URLDecoder
import java.nio.file.Paths
private val unsupportedPseudoElementRegex = Regex("::?(before|after|first-letter|first-line|marker|selection)", RegexOption.IGNORE_CASE)
private fun Element.getCfiPath(): String {
val path = mutableListOf<Int>()
var currentNode: Node? = this
while (currentNode != null && (currentNode !is Element || currentNode.tagName() != "body")) {
val parent = currentNode.parent() ?: break
val children = parent.childNodes().filter { node ->
node is Element || (node is TextNode && node.text().trim().isNotEmpty())
}
val nodeIndex = children.indexOf(currentNode)
if (nodeIndex == -1) {
currentNode = parent
continue
}
val cfiIndex = (nodeIndex * 2) + 2
path.add(0, cfiIndex)
currentNode = parent
}
path.add(0, 4)
return "/" + path.joinToString("/")
}
private fun String.capitalizeWords(): String =
split(' ').joinToString(" ") { word ->
if (word.isNotEmpty()) word.replaceFirstChar { it.titlecase() } else ""
}
/**
* The public entry point for converting HTML to a list of [SemanticBlock]s.
* This function sets up a parsing context and delegates the work to a [SemanticHtmlParser] instance.
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun htmlToSemanticBlocks(
html: String,
cssRules: OptimizedCssRules,
textStyle: TextStyle,
chapterAbsPath: String,
extractionBasePath: String,
density: Density,
fontFamilyMap: Map<String, FontFamily>,
constraints: Constraints,
imageDimensionsCache: Map<String, Pair<Float, Float>> = emptyMap(),
mathSvgCache: Map<String, String> = emptyMap()
): List<SemanticBlock> {
return SemanticHtmlParser(
cssRules,
textStyle,
chapterAbsPath,
extractionBasePath,
density,
fontFamilyMap,
constraints,
imageDimensionsCache,
mathSvgCache
).parse(html)
}
/**
* A stateful parser that holds the context for a single HTML-to-SemanticBlock conversion.
*/
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private class SemanticHtmlParser(
cssRules: OptimizedCssRules,
private val textStyle: TextStyle,
private val chapterAbsPath: String,
private val extractionBasePath: String,
private val density: Density,
fontFamilyMap: Map<String, FontFamily>,
private val constraints: Constraints,
private val imageDimensionsCache: Map<String, Pair<Float, Float>>,
private val mathSvgCache: Map<String, String>
) {
private val styleCache = mutableMapOf<String, CssStyle>()
private var combinedRules: OptimizedCssRules = cssRules
private val currentFontFamilyMap: MutableMap<String, FontFamily> = fontFamilyMap.toMutableMap()
private var nextBlockIndex = 0
fun parse(html: String): List<SemanticBlock> {
val document = Jsoup.parse(html, chapterAbsPath)
val inlineCssContent = document.head().select("style").joinToString(separator = "\n") { it.data() }
if (inlineCssContent.isNotBlank()) {
Timber.d("Found inline <style> content in $chapterAbsPath. Parsing...")
val inlineParseResult = CssParser.parse(
cssContent = inlineCssContent,
cssPath = chapterAbsPath,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // Semantic parsing is always theme-agnostic
)
if (inlineParseResult.fontFaces.isNotEmpty()) {
val newFonts = loadFontFamilies(inlineParseResult.fontFaces, extractionBasePath)
if (newFonts.isNotEmpty()) {
currentFontFamilyMap.putAll(newFonts)
}
}
combinedRules = combinedRules.merge(inlineParseResult.rules)
}
val body = document.body()
return body.children().flatMap { childElement ->
parseNodeToSemanticBlocks(childElement, getElementStyle(body))
}
}
private fun parseNodeToSemanticBlocks(
element: Element,
inheritedStyle: CssStyle
): List<SemanticBlock> {
val elementOwnStyle = getElementStyle(element)
val finalBlockStyle = elementOwnStyle.blockStyle.copy(
listStyleType = elementOwnStyle.blockStyle.listStyleType ?: inheritedStyle.blockStyle.listStyleType,
listStyleImage = elementOwnStyle.blockStyle.listStyleImage ?: inheritedStyle.blockStyle.listStyleImage
)
val finalStyle = elementOwnStyle.copy(
spanStyle = inheritedStyle.spanStyle.merge(elementOwnStyle.spanStyle),
paragraphStyle = inheritedStyle.paragraphStyle.merge(elementOwnStyle.paragraphStyle),
blockStyle = finalBlockStyle,
fontFamilies = elementOwnStyle.fontFamilies.ifEmpty { inheritedStyle.fontFamilies },
fontSize = if (elementOwnStyle.fontSize.isSpecified) elementOwnStyle.fontSize else inheritedStyle.fontSize,
textTransform = elementOwnStyle.textTransform ?: inheritedStyle.textTransform,
hyphens = elementOwnStyle.hyphens ?: inheritedStyle.hyphens,
fontVariantNumeric = elementOwnStyle.fontVariantNumeric ?: inheritedStyle.fontVariantNumeric,
textEmphasis = elementOwnStyle.textEmphasis ?: inheritedStyle.textEmphasis
)
if (finalStyle.display == "none") return emptyList()
return elementToSemanticBlocks(element, finalStyle)
}
private fun getElementDescriptor(element: Element): String {
return buildString {
append(element.tagName())
val id = element.id()
if (id.isNotEmpty()) append('#').append(id)
val classes = element.classNames()
if (classes.isNotEmpty()) append('.').append(classes.sorted().joinToString("."))
}
}
private fun getElementStyle(element: Element): CssStyle {
val cacheKey = getElementDescriptor(element)
val baseStyle = styleCache.getOrPut(cacheKey) {
val potentialRules = mutableListOf<CssRule>()
combinedRules.byTag[element.tagName()]?.let { potentialRules.addAll(it) }
element.id().takeIf { it.isNotEmpty() }?.let { id ->
combinedRules.byId[id]?.let { potentialRules.addAll(it) }
}
element.classNames().forEach { className ->
combinedRules.byClass[className]?.let { potentialRules.addAll(it) }
}
potentialRules.addAll(combinedRules.otherComplex)
val matchingRules = potentialRules.filter { rule ->
if (unsupportedPseudoElementRegex.containsMatchIn(rule.selector.selector)) return@filter false
try {
element.`is`(rule.selector.selector)
} catch (e: Selector.SelectorParseException) {
Timber.w(e, "Jsoup failed to parse selector '${rule.selector.selector}'.")
false
}
}
matchingRules.sortedBy { it.selector.specificity }.fold(CssStyle()) { acc, rule ->
acc.merge(rule.style)
}
}
var elementStyle = baseStyle
val inlineStyleAttribute = element.attr("style")
if (inlineStyleAttribute.isNotBlank()) {
val inlineStyle = CssParser.parseProperties(inlineStyleAttribute, textStyle.fontSize.value, density.density, constraints, onlyImportant = false, isDarkTheme = false)
elementStyle = elementStyle.merge(inlineStyle)
}
element.attr("align").takeIf { it.isNotBlank() }?.let { align ->
val textAlign = when (align.lowercase()) {
"center" -> TextAlign.Center; "right" -> TextAlign.End
"justify" -> TextAlign.Justify; "left" -> TextAlign.Start
else -> null
}
if (textAlign != null) {
elementStyle = elementStyle.merge(CssStyle(paragraphStyle = ParagraphStyle(textAlign = textAlign)))
}
}
return elementStyle
}
private fun elementToSemanticBlocks(
element: Element,
elementStyle: CssStyle
): List<SemanticBlock> {
val elementId = element.id().ifBlank { null }
val cfi = element.getCfiPath()
if (element.tagName().equals("br", ignoreCase = true)) {
return listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, isExplicitLineBreak = true, blockIndex = nextBlockIndex++))
}
if (elementStyle.blockStyle.display == "flex") {
val children = element.children().flatMap { child ->
parseNodeToSemanticBlocks(child, elementStyle)
}
return listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
}
val result = when (val tagName = element.tagName().lowercase()) {
"div", "header", "section", "article", "aside", "main", "footer", "nav", "figure" -> {
val hasBoxStyles = elementStyle.blockStyle.backgroundColor.isSpecified ||
elementStyle.blockStyle.border != null ||
elementStyle.blockStyle.padding != BoxBorders() ||
elementStyle.blockStyle.borderRadius > 0.dp
if (hasBoxStyles) {
val children = element.children().flatMap { child ->
parseNodeToSemanticBlocks(child, elementStyle)
}
listOf(SemanticFlexContainer(children, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else {
parseContainer(element, elementStyle)
}
}
"svg" -> parseSvgElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"table" -> parseTableElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"math-placeholder" -> parseMathPlaceholderToSemantic(element, elementStyle)
"img" -> parseImageElementToSemantic(element, elementStyle)?.let { listOf(it) } ?: emptyList()
"h1", "h2", "h3", "h4", "h5", "h6" -> {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
if (text.isNotBlank()) {
val level = tagName.substring(1).toIntOrNull() ?: 1
listOf(SemanticHeader(level, text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
}
"hr" -> listOf(SemanticSpacer(style = elementStyle, elementId = elementId, cfi = cfi, blockIndex = nextBlockIndex++))
"ul", "ol" -> parseListElementToSemantic(element, elementStyle)
else -> {
if (element.isBlock) {
parseContainer(element, elementStyle)
} else {
val (text, spans) = buildSemanticTextAndSpans(element, elementStyle)
if (text.isNotBlank()) {
listOf(SemanticParagraph(text, spans, elementStyle, elementId, cfi, blockIndex = nextBlockIndex++))
} else emptyList()
}
}
}
return if (elementId != null && result.isNotEmpty()) {
val first = result.first()
if (first.elementId == null) {
listOf(first.withElementId(elementId)) + result.drop(1)
} else result
} else result
}
private fun parseContainer(element: Element, style: CssStyle): List<SemanticBlock> {
val children = mutableListOf<SemanticBlock>()
val textNodesBuffer = mutableListOf<Node>()
fun flushTextBuffer() {
if (textNodesBuffer.isEmpty()) return
val (text, spans) = buildSemanticTextAndSpansFromNodes(textNodesBuffer, style)
if (text.isNotBlank()) {
children.add(SemanticParagraph(text, spans, style, element.id().ifBlank { null }, element.getCfiPath(), blockIndex = nextBlockIndex++)) }
textNodesBuffer.clear()
}
element.childNodes().forEach { node ->
if (node is Element) {
val isEffectivelyBlock = node.isBlock || node.tagName().lowercase() in listOf("img", "svg", "math-placeholder", "hr")
if (isEffectivelyBlock) {
flushTextBuffer()
children.addAll(parseNodeToSemanticBlocks(node, style))
} else {
textNodesBuffer.add(node)
}
} else {
textNodesBuffer.add(node)
}
}
flushTextBuffer()
return children
}
private fun buildSemanticTextAndSpans(
rootElement: Element,
rootStyle: CssStyle
): Pair<String, List<SemanticSpan>> {
return buildSemanticTextAndSpansFromNodes(rootElement.childNodes(), rootStyle)
}
private fun buildSemanticTextAndSpansFromNodes(
nodes: List<Node>,
rootStyle: CssStyle
): Pair<String, List<SemanticSpan>> {
val textBuilder = StringBuilder()
val spans = mutableListOf<SemanticSpan>()
fun processNode(node: Node, inheritedStyle: CssStyle) {
when (node) {
is TextNode -> {
var text = node.wholeText.replace('\n', ' ')
when (inheritedStyle.textTransform) {
"uppercase" -> text = text.uppercase()
"lowercase" -> text = text.lowercase()
"capitalize" -> text = text.capitalizeWords()
}
textBuilder.append(text)
}
is Element -> {
if (node.tagName().lowercase() == "br") {
textBuilder.append('\n'); return
}
val currentElementStyle = getElementStyle(node)
val newStyle = inheritedStyle.merge(currentElementStyle)
val startIndex = textBuilder.length
node.childNodes().forEach { processNode(it, newStyle) }
val endIndex = textBuilder.length
val elementId = node.id().ifBlank { null }
val isAnchor = node.tagName().lowercase() == "a" || elementId != null
// Capture span if it has content OR if it has an ID (anchor)
if (startIndex < endIndex || elementId != null) {
val href = if (node.tagName().lowercase() == "a") node.attr("href").ifBlank { null } else null
spans.add(SemanticSpan(
start = startIndex,
end = endIndex,
style = newStyle,
linkHref = href,
tag = node.tagName().lowercase(),
elementId = elementId // Pass the ID here
))
}
}
}
}
nodes.forEach { processNode(it, rootStyle) }
var processedText = textBuilder.toString()
if (processedText.isNotEmpty() && processedText.last().isWhitespace()) {
// 1. Find the index where trailing whitespace begins
var newLength = processedText.length
while (newLength > 0 && processedText[newLength - 1].isWhitespace()) {
newLength--
}
// 2. Cut the text
processedText = processedText.substring(0, newLength)
// 3. Filter or Cap spans so they don't point to indices that no longer exist
val adjustedSpans = spans.mapNotNull { span ->
if (span.start >= newLength) {
// Span started in the whitespace area, remove it
null
} else if (span.end > newLength) {
// Span ended in the whitespace area, cap it
span.copy(end = newLength)
} else {
span
}
}
return processedText to adjustedSpans
}
return processedText to spans
}
private fun parseMathPlaceholderToSemantic(element: Element, style: CssStyle): List<SemanticBlock> {
val uniqueId = element.id()
val svgContent = mathSvgCache[uniqueId]
val altText = element.attr("alttext").ifBlank { "Equation" }
var svgWidth: String? = null
var svgHeight: String? = null
var svgViewBox: String? = null
if (svgContent != null) {
val svgDoc = Jsoup.parse(svgContent)
svgDoc.selectFirst("svg")?.let {
svgWidth = it.attr("width")
svgHeight = it.attr("height")
svgViewBox = it.attr("viewBox")
}
}
return listOf(
SemanticMath(
svgContent, altText, svgWidth, svgHeight, svgViewBox,
isFromMathJax = true, style = style,
elementId = element.id().ifBlank { null }, cfi = element.getCfiPath(), blockIndex = nextBlockIndex++
)
)
}
private fun parseSvgElementToSemantic(svgElement: Element, style: CssStyle): SemanticBlock? {
val children = svgElement.children()
val imageElement = children.firstOrNull()?.takeIf { children.size == 1 && it.tagName() == "image" }
if (imageElement != null) {
Timber.d("Detected SVG acting as a wrapper for an image. Parsing as SemanticImage.")
val href = imageElement.attr("href").ifBlank { imageElement.attr("xlink:href") }
if (href.isBlank()) return null
val imageFile = resolveImagePath(href) ?: return null
val (width, height) = imageDimensionsCache[imageFile.absolutePath] ?: run {
try {
BitmapFactory.Options().apply { inJustDecodeBounds = true }
.also { BitmapFactory.decodeFile(imageFile.absolutePath, it) }
.let { Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) }
} catch (_: Exception) {
Pair(null, null)
}
}
return SemanticImage(
path = imageFile.absolutePath,
altText = svgElement.selectFirst("title")?.text() ?: "Cover Image",
intrinsicWidth = width,
intrinsicHeight = height,
style = style,
elementId = svgElement.id().ifBlank { null },
cfi = svgElement.getCfiPath(),
blockIndex = nextBlockIndex++
)
}
Timber.d("Parsing genuine SVG content into SemanticMath block.")
val title = svgElement.selectFirst("title")?.text()
val desc = svgElement.selectFirst("desc")?.text()
val altText = title ?: desc ?: "SVG Image"
return SemanticMath(
svgContent = svgElement.outerHtml(),
altText = altText,
style = style,
elementId = svgElement.id().ifBlank { null },
cfi = svgElement.getCfiPath(),
svgWidth = svgElement.attr("width").ifBlank { null },
svgHeight = svgElement.attr("height").ifBlank { null },
svgViewBox = svgElement.attr("viewBox").ifBlank { null },
isFromMathJax = false,
blockIndex = nextBlockIndex++
)
}
private fun parseImageElementToSemantic(element: Element, style: CssStyle): SemanticBlock? {
val src = element.attr("src")
if (src.isBlank()) return null
val imageFile = resolveImagePath(src) ?: return null
if (imageFile.extension.equals("svg", ignoreCase = true)) {
return try {
val svgContent = imageFile.readText()
val svgElement = Jsoup.parseBodyFragment(svgContent).body().children().firstOrNull()
svgElement?.let { parseSvgElementToSemantic(it, style) }
} catch (e: Exception) {
Timber.e(e, "Failed to read SVG from <img> tag: ${imageFile.path}")
null
}
}
val (width, height) = imageDimensionsCache[imageFile.absolutePath] ?: run {
try {
BitmapFactory.Options().apply { inJustDecodeBounds = true }
.also { BitmapFactory.decodeFile(imageFile.absolutePath, it) }
.let { Pair(it.outWidth.toFloat(), it.outHeight.toFloat()) }
} catch (_: Exception) {
Pair(null, null)
}
}
return SemanticImage(
path = imageFile.absolutePath,
altText = element.attr("alt"),
intrinsicWidth = width,
intrinsicHeight = height,
style = style,
elementId = element.id().ifBlank { null },
cfi = element.getCfiPath(),
blockIndex = nextBlockIndex++
)
}
private fun resolveImagePath(src: String): File? {
if (src.isBlank()) return null
val decodedSrc = try { URLDecoder.decode(src, "UTF-8") } catch (_: Exception) { src }
val parentPath = File(chapterAbsPath).parent ?: ""
val relativePath = Paths.get(parentPath, decodedSrc).normalize().toString()
val fromRelativeFile = File(extractionBasePath, relativePath)
try {
if (fromRelativeFile.exists()) return fromRelativeFile.canonicalFile
val fromRootFile = File(extractionBasePath, decodedSrc)
if (fromRootFile.exists()) return fromRootFile.canonicalFile
} catch (e: java.io.IOException) {
Timber.e(e, "Could not get canonical path for image at $src")
return null
}
Timber.w("Image not found. Tried: ${fromRelativeFile.absolutePath} and ${File(extractionBasePath, decodedSrc).absolutePath}")
return null
}
private fun parseListElementToSemantic(listElement: Element, listStyle: CssStyle): List<SemanticBlock> {
val isOrdered = listElement.tagName().lowercase() == "ol"
val items = listElement.children().mapNotNull { child ->
if (child.tagName().lowercase() != "li") return@mapNotNull null
val itemStyle = listStyle.merge(getElementStyle(child))
val (text, spans) = buildSemanticTextAndSpans(child, itemStyle)
val imageSrc = itemStyle.blockStyle.listStyleImage?.let { resolveImagePath(it)?.absolutePath }
SemanticListItem(text, spans, itemStyle, child.id().ifBlank { null }, child.getCfiPath(), 0, imageSrc, blockIndex = nextBlockIndex++)
}
return listOf(SemanticList(items, isOrdered, listStyle, listElement.id().ifBlank { null }, listElement.getCfiPath(), blockIndex = nextBlockIndex++))
}
private fun parseTableElementToSemantic(tableElement: Element, tableStyle: CssStyle): SemanticTable? {
val rows = tableElement.select("tr").mapNotNull { rowElement ->
val rowStyle = getElementStyle(rowElement)
if (rowStyle.display == "none") return@mapNotNull null
val cells = rowElement.children().mapNotNull { cellElement ->
val tagName = cellElement.tagName().lowercase()
if (tagName !in listOf("td", "th")) return@mapNotNull null
var cellCssStyle = getElementStyle(cellElement)
if (cellCssStyle.display == "none") return@mapNotNull null
if (!cellCssStyle.blockStyle.backgroundColor.isSpecified) {
if (rowStyle.blockStyle.backgroundColor.isSpecified) {
cellCssStyle = cellCssStyle.copy(
blockStyle = cellCssStyle.blockStyle.copy(
backgroundColor = rowStyle.blockStyle.backgroundColor
)
)
}
}
val cellContent = parseContainer(cellElement, cellCssStyle)
SemanticTableCell(cellContent, tagName == "th", cellElement.attr("colspan").toIntOrNull() ?: 1, cellCssStyle)
}
cells.ifEmpty { null }
}
if (rows.isEmpty()) return null
return SemanticTable(rows, tableStyle, tableElement.id().ifBlank { null }, tableElement.getCfiPath(), blockIndex = nextBlockIndex++)
}
}

View file

@ -0,0 +1,38 @@
// IPaginator.kt
package com.aryan.reader.paginatedreader
import androidx.compose.runtime.Stable
import com.aryan.reader.SearchResult
import kotlinx.coroutines.flow.Flow
@Stable
interface IPaginator {
val totalPageCount: Int
val isLoading: Boolean
val generation: Int
val pageShiftRequest: Flow<Int>
fun getPageContent(pageIndex: Int): Page?
fun getChapterPathForPage(pageIndex: Int): String?
fun getPlainTextForChapter(chapterIndex: Int): String?
fun navigateToHref(
currentChapterAbsPath: String,
href: String,
onNavigationComplete: (pageIndex: Int) -> Unit
)
fun findPageForSearchResult(
result: SearchResult,
onResult: (pageIndex: Int) -> Unit
)
fun findPageForAnchor(
chapterIndex: Int,
anchor: String?,
onResult: (pageIndex: Int) -> Unit
)
fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit)
fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int?
fun findChapterIndexForPage(pageIndex: Int): Int?
fun getCfiForPage(pageIndex: Int): String?
fun onUserScrolledTo(pageIndex: Int)
fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List<String>): String?
}

View file

@ -0,0 +1,262 @@
// Locator.kt
package com.aryan.reader.paginatedreader
import android.content.Context
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.ProcessedChapter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
data class Locator(
val chapterIndex: Int,
val blockIndex: Int,
val charOffset: Int
)
/**
* Converts between view-specific locators (like CFI) and the abstract Locator model.
*/
@OptIn(ExperimentalSerializationApi::class)
class LocatorConverter(
private val bookCacheDao: BookCacheDao,
private val proto: ProtoBuf,
private val context: Context
) {
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
private suspend fun processAndCacheChapter(book: EpubBook, chapterIndex: Int): List<SemanticBlock>? = withContext(Dispatchers.IO) {
try {
val chapter = book.chapters.getOrNull(chapterIndex) ?: return@withContext null
// 1. Parse CSS from the book
var parsingCssRules = OptimizedCssRules()
val density = Density(context)
val displayMetrics = context.resources.displayMetrics
val constraints = Constraints(maxWidth = displayMetrics.widthPixels, maxHeight = displayMetrics.heightPixels)
book.css.forEach { (path, content) ->
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = 16f, // A reasonable default for non-rendering parsing
density = density.density,
constraints = constraints,
isDarkTheme = false
)
parsingCssRules = parsingCssRules.merge(bookCssResult.rules)
}
// 2. Parse HTML to SemanticBlocks
val semanticBlocks = htmlToSemanticBlocks(
html = chapter.htmlContent,
cssRules = parsingCssRules,
textStyle = TextStyle(), // Not used for rendering, so a default is fine
chapterAbsPath = chapter.absPath,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = emptyMap(),
constraints = constraints
)
// 3. Serialize and cache the result
val protoBytes = proto.encodeToByteArray(semanticBlocks)
val newCacheEntry = ProcessedChapter(
bookId = book.title,
chapterIndex = chapterIndex,
contentBlocksProto = protoBytes,
estimatedPageCount = 0 // Page count is not relevant for locator conversion
)
bookCacheDao.insertProcessedChapters(listOf(newCacheEntry))
Timber.i("On-demand processing SUCCESS for chapter $chapterIndex.")
semanticBlocks
} catch (e: Exception) {
Timber.e(e, "On-demand processing FAILED for chapter $chapterIndex")
null
}
}
/**
* Converts a CFI string from the WebView into an abstract Locator.
*/
suspend fun getLocatorFromCfi(book: EpubBook, chapterIndex: Int, cfi: String): Locator? = withContext(Dispatchers.IO) {
Timber.d("getLocatorFromCfi: Starting conversion for book='${book.title}', chapter=$chapterIndex, cfi='$cfi'")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = chapterIndex)
val allBlocks = if (processedChapter != null) {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} else {
Timber.w("getLocatorFromCfi: Chapter $chapterIndex not in DB. Triggering on-demand processing.")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
processAndCacheChapter(book, chapterIndex)
} else {
Timber.e("On-demand processing requires API 34+, cannot proceed.")
null
}
}
if (allBlocks == null) {
Timber.w("getLocatorFromCfi: FAILED. Could not get or process semantic blocks for chapter $chapterIndex.")
return@withContext null
}
val (baseCfiPath, charOffset) = cfi.split(':').let {
it[0] to (it.getOrNull(1)?.toIntOrNull() ?: 0)
}
Timber.d("getLocatorFromCfi: Parsed CFI into basePath='$baseCfiPath' and charOffset=$charOffset")
val bestMatch = findBestMatchingBlock(allBlocks, baseCfiPath)
if (bestMatch != null) {
Timber.i("getLocatorFromCfi: SUCCESS. Found best match. Block index: ${bestMatch.blockIndex}, Block CFI: '${bestMatch.cfi}'")
Locator(
chapterIndex = chapterIndex,
blockIndex = bestMatch.blockIndex,
charOffset = charOffset
)
} else {
Timber.w("getLocatorFromCfi: FAILED. No matching block found for CFI base path '$baseCfiPath'.")
null
}
}
private fun findBestMatchingBlock(blocks: List<SemanticBlock>, inputCfi: String): SemanticBlock? {
val flattenedBlocks = mutableListOf<SemanticBlock>()
fun flatten(blockList: List<SemanticBlock>) {
for (block in blockList) {
flattenedBlocks.add(block)
when (block) {
is SemanticFlexContainer -> flatten(block.children)
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> flatten(cell.content) } }
is SemanticList -> flatten(block.items)
else -> Unit
}
}
}
flatten(blocks)
if (flattenedBlocks.isEmpty()) return null
flattenedBlocks.mapNotNull { it.cfi }
val bestMatch = flattenedBlocks
.filter { it.cfi != null }
.map { block ->
val blockCfi = block.cfi!!
var i = inputCfi.length - 1
var j = blockCfi.length - 1
var length = 0
while (i >= 0 && j >= 0 && inputCfi[i] == blockCfi[j]) {
length++
i--
j--
}
Pair(block, length)
}
.maxByOrNull { it.second }
?.first
return bestMatch
}
suspend fun getCfiFromLocator(bookId: String, locator: Locator): String? = withContext(Dispatchers.IO) {
Timber.d("getCfiFromLocator: Attempting to get CFI from locator: $locator")
val processedChapter = bookCacheDao.getProcessedChapter(bookId = bookId, chapterIndex = locator.chapterIndex)
if (processedChapter == null) {
Timber.w("getCfiFromLocator: FAILED. Could not find processed chapter ${locator.chapterIndex} in database.")
return@withContext null
}
val blocks = proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
val foundBlock = findBlockByBlockIndex(blocks, locator.blockIndex)
if (foundBlock != null) {
foundBlock.cfi?.let { cfi ->
val finalCfi = if (locator.charOffset > 0) {
"$cfi:${locator.charOffset}"
} else {
cfi
}
Timber.i("getCfiFromLocator: SUCCESS. Found block ${foundBlock.blockIndex} with CFI '${foundBlock.cfi}'. Final CFI: '$finalCfi'")
finalCfi
}
} else {
Timber.w("getCfiFromLocator: FAILED. Could not find block with index ${locator.blockIndex} in chapter ${locator.chapterIndex}.")
null
}
}
private fun findBlockByBlockIndex(blocks: List<SemanticBlock>, targetBlockIndex: Int): SemanticBlock? {
val queue = ArrayDeque(blocks)
while (queue.isNotEmpty()) {
val block = queue.removeAt(0)
if (block.blockIndex == targetBlockIndex) {
Timber.v("findBlockByBlockIndex: Found match for block index $targetBlockIndex.")
return block
}
// Recurse into nested blocks
when (block) {
is SemanticFlexContainer -> queue.addAll(block.children)
is SemanticTable -> block.rows.forEach { row -> row.forEach { cell -> queue.addAll(cell.content) } }
is SemanticList -> queue.addAll(block.items)
else -> Unit
}
}
Timber.w("findBlockByBlockIndex: No block found for target index $targetBlockIndex.")
return null
}
suspend fun getTextOffset(book: EpubBook, locator: Locator): Int? = withContext(Dispatchers.IO) {
val processedChapter = bookCacheDao.getProcessedChapter(bookId = book.title, chapterIndex = locator.chapterIndex)
val allBlocks = if (processedChapter != null) {
proto.decodeFromByteArray<List<SemanticBlock>>(processedChapter.contentBlocksProto)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
processAndCacheChapter(book, locator.chapterIndex)
} else null
} ?: return@withContext null
var offset = 0
val separatorLength = 1
fun traverse(blocks: List<SemanticBlock>): Boolean {
for (block in blocks) {
if (block.blockIndex == locator.blockIndex) {
offset += locator.charOffset
return true
}
if (block is SemanticTextBlock) {
offset += block.text.length + separatorLength
}
val children = when (block) {
is SemanticFlexContainer -> block.children
is SemanticTable -> block.rows.flatten().flatMap { it.content }
is SemanticList -> block.items
is SemanticWrappingBlock -> block.paragraphsToWrap
else -> emptyList()
}
if (children.isNotEmpty()) {
if (traverse(children)) return true
}
}
return false
}
if (traverse(allBlocks)) {
return@withContext offset
}
return@withContext null
}
}

View file

@ -0,0 +1,227 @@
// MathMLRenderer.kt
package com.aryan.reader.paginatedreader
import android.annotation.SuppressLint
import android.content.Context
import android.os.Handler
import android.os.Looper
import timber.log.Timber
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
sealed class RenderResult {
data class Success(val svg: String) : RenderResult()
data class Failure(val altText: String) : RenderResult()
}
class MathMLRenderer(private val context: Context) {
private var webView: WebView? = null
private val handler = Handler(Looper.getMainLooper())
private var isMathJaxReady = false
private val readySignal = CompletableDeferred<Boolean>()
sealed class Job {
data class Render(
val mathML: String,
val continuation: (RenderResult) -> Unit
) : Job()
}
private val jobQueue = mutableListOf<Job.Render>()
private var isProcessing = false
init {
handler.post {
setupWebView()
}
}
suspend fun awaitReady(): Boolean {
Timber.d("awaitReady: Waiting for WebView and MathJax initialization...")
return withTimeoutOrNull(10_000) {
readySignal.await()
} ?: run {
Timber.e("awaitReady: Timed out waiting for renderer to become ready.")
destroy()
false
}
}
private fun setupWebView() {
try {
WebView.setWebContentsDebuggingEnabled(true)
webView = WebView(context).apply {
@SuppressLint("SetJavaScriptEnabled")
settings.javaScriptEnabled = true
settings.allowFileAccess = true
settings.domStorageEnabled = true
addJavascriptInterface(WebAppInterface { svg ->
completeCurrentJob(RenderResult.Success(svg))
}, "AndroidBridge")
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
Timber.d("WebView page finished loading: $url")
}
}
webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean {
Timber.d("${consoleMessage.message()} -- From line " +
"${consoleMessage.lineNumber()} of ${consoleMessage.sourceId()}"
)
return true
}
}
loadUrl("file:///android_asset/MathML-template.html")
}
} catch (e: Exception) {
Timber.e(e, "Failed to initialize WebView")
webView = null
readySignal.complete(false)
}
}
suspend fun render(mathML: String, originalAltText: String): RenderResult {
if (!awaitReady()) {
Timber.e("WebView is not available or failed to initialize. Failing render.")
return RenderResult.Failure(originalAltText)
}
return suspendCancellableCoroutine { continuation ->
val job = Job.Render(mathML) { result ->
if (continuation.isActive) {
continuation.resume(result)
}
}
// Add job to the queue and start processing if not already
synchronized(jobQueue) {
jobQueue.add(job)
if (!isProcessing) {
processNextJob()
}
}
continuation.invokeOnCancellation {
synchronized(jobQueue) {
jobQueue.remove(job)
}
}
}
}
private fun processNextJob() {
synchronized(jobQueue) {
if (jobQueue.isEmpty()) {
isProcessing = false
return
}
isProcessing = true
}
handler.post {
executeRender()
}
}
private fun executeRender() {
if (!isMathJaxReady) {
Timber.d("executeRender called but MathJax not ready yet. Retrying...")
handler.postDelayed({ executeRender() }, 100)
return
}
val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
if (job == null) {
isProcessing = false
return
}
// Escape backticks in the MathML string to prevent breaking the JS template literal
val mathMLForJs = job.mathML.replace("`", "\\`")
val script = """
(function() {
console.log("MATH_DIAGNOSTIC: Starting MathML to SVG conversion.");
const mathMLContent = `${mathMLForJs}`;
console.log("MATH_DIAGNOSTIC: Input MathML: " + mathMLContent);
MathJax.mathml2svgPromise(mathMLContent).then(function (node) {
console.log("MATH_DIAGNOSTIC: mathml2svgPromise successful.");
var svgElement = node.querySelector('svg');
if (svgElement) {
svgElement.style.fill = 'currentColor';
var svgOutput = svgElement.outerHTML;
var width = svgElement.getAttribute('width');
var height = svgElement.getAttribute('height');
var viewBox = svgElement.getAttribute('viewBox');
console.log('MATH_SIZE_DIAGNOSTIC: Generated SVG details -> width: ' + width + ', height: ' + height + ', viewBox: ' + viewBox + ', length: ' + svgOutput.length);
console.log("MATH_DIAGNOSTIC: SVG generated: " + svgOutput);
AndroidBridge.onSvgReady(svgOutput);
} else {
console.error("MATH_DIAGNOSTIC: SVG element not found in MathJax output.");
AndroidBridge.onSvgReady('');
}
}).catch((err) => {
console.error("MATH_DIAGNOSTIC: MathJax conversion error:", err);
AndroidBridge.onSvgReady('');
});
})();
""".trimIndent()
webView?.evaluateJavascript(script, null)
}
private fun completeCurrentJob(result: RenderResult) {
val job = synchronized(jobQueue) {
if (jobQueue.isNotEmpty()) jobQueue.removeAt(0) else null
}
job?.continuation?.invoke(result)
processNextJob()
}
fun destroy() {
handler.post {
webView?.destroy()
webView = null
Timber.d("MathMLRenderer WebView destroyed.")
}
synchronized(jobQueue) {
jobQueue.clear()
isProcessing = false
}
}
private inner class WebAppInterface(private val onResult: (String) -> Unit) {
@Suppress("unused")
@JavascriptInterface
fun onSvgReady(svg: String) {
if (svg.isNotBlank()) {
Timber.d("onSvgReady SUCCESS. Received SVG length: ${svg.length}")
onResult(svg)
} else {
Timber.e("onSvgReady FAILURE. Received empty SVG.")
val job = synchronized(jobQueue) { jobQueue.firstOrNull() }
val altText = job?.mathML?.substringAfter("alttext=\"", "")?.substringBefore("\"") ?: "MathML rendering failed"
completeCurrentJob(RenderResult.Failure(altText))
}
}
@Suppress("unused")
@JavascriptInterface
fun onMathJaxReady() {
isMathJaxReady = true
Timber.d("onMathJaxReady: MathJax is ready.")
if (!readySignal.isCompleted) {
readySignal.complete(true)
}
}
}
}

View file

@ -0,0 +1,59 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.epub.EpubChapter
import kotlin.math.ceil
import kotlin.math.max
object PageCountEstimator {
/**
* heuristic factor: approximate percentage of HTML string that is actual text vs tags.
* 0.6 means we assume 60% of the string length is visible text.
*/
private const val HTML_TEXT_DENSITY_FACTOR = 0.6f
/**
* Calculates an approximate page count instantly without rendering.
*/
fun estimateChapterPageCount(
chapter: EpubChapter,
constraints: Constraints,
textStyle: TextStyle,
density: Density
): Int {
val screenWidth = constraints.maxWidth
val screenHeight = constraints.maxHeight
val screenArea = screenWidth * screenHeight
if (screenArea <= 0) return 1
val fontSizePx = with(density) { textStyle.fontSize.toPx() }
val lineHeightPx = if (textStyle.lineHeight.isSpecified) {
with(density) { textStyle.lineHeight.toPx() }
} else {
fontSizePx * 1.4f
}
val avgCharWidthPx = fontSizePx * 0.6f
val charArea = avgCharWidthPx * lineHeightPx
val rawCharsPerPage = screenArea / charArea
val packingFactor = 0.75f
val estimatedVisibleCharsPerPage = (rawCharsPerPage * packingFactor).toInt()
if (estimatedVisibleCharsPerPage <= 0) return 1
val estimatedTextLength = (chapter.htmlContent.length * HTML_TEXT_DENSITY_FACTOR).toInt()
val pages = ceil(estimatedTextLength.toFloat() / estimatedVisibleCharsPerPage).toInt()
return max(1, pages)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,353 @@
// PaginatedReaderData.kt
@file:OptIn(ExperimentalSerializationApi::class)
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.paginatedreader.serialization.AnnotatedStringSerializer
import com.aryan.reader.paginatedreader.serialization.ColorSerializer
import com.aryan.reader.paginatedreader.serialization.DpSerializer
import com.aryan.reader.paginatedreader.serialization.ParagraphStyleSerializer
import com.aryan.reader.paginatedreader.serialization.SpanStyleSerializer
import com.aryan.reader.paginatedreader.serialization.TextAlignSerializer
import com.aryan.reader.paginatedreader.serialization.TextUnitSerializer
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
@Serializable
data class BlockStyle(
@ProtoNumber(1) val margin: BoxBorders = BoxBorders(),
@ProtoNumber(2) val padding: BoxBorders = BoxBorders(),
@ProtoNumber(3) @Serializable(with = DpSerializer::class) val width: Dp = Dp.Unspecified,
@ProtoNumber(4) @Serializable(with = DpSerializer::class) val maxWidth: Dp = Dp.Unspecified,
@ProtoNumber(5) @Serializable(with = DpSerializer::class) val height: Dp = Dp.Unspecified,
@ProtoNumber(6) @Serializable(with = ColorSerializer::class) val backgroundColor: Color = Color.Unspecified,
@ProtoNumber(7) val border: BorderStyle? = null,
@ProtoNumber(8) val listStyleType: String? = null,
@ProtoNumber(9) val listStyleImage: String? = null,
@ProtoNumber(10) val pageBreakInsideAvoid: Boolean = false,
@ProtoNumber(11) val pageBreakAfterAvoid: Boolean = false,
@ProtoNumber(12) val boxSizing: String? = null,
@ProtoNumber(13) val float: String? = null,
@ProtoNumber(14) val clear: String? = null,
@ProtoNumber(15) val position: String? = null,
@ProtoNumber(16) @Serializable(with = DpSerializer::class) val top: Dp = Dp.Unspecified,
@ProtoNumber(17) @Serializable(with = DpSerializer::class) val right: Dp = Dp.Unspecified,
@ProtoNumber(18) @Serializable(with = DpSerializer::class) val bottom: Dp = Dp.Unspecified,
@ProtoNumber(19) @Serializable(with = DpSerializer::class) val left: Dp = Dp.Unspecified,
@ProtoNumber(20) val display: String? = null,
@ProtoNumber(21) val flexDirection: String? = null,
@ProtoNumber(22) val justifyContent: String? = null,
@ProtoNumber(23) val alignItems: String? = null,
@ProtoNumber(24) val horizontalAlign: String? = null,
@ProtoNumber(25) val filter: String? = null,
@ProtoNumber(26) val borderCollapse: String? = null,
@ProtoNumber(27) @Serializable(with = DpSerializer::class) val borderSpacing: Dp = 0.dp,
@ProtoNumber(28) @Serializable(with = DpSerializer::class) val borderRadius: Dp = 0.dp
) {
fun merge(other: BlockStyle): BlockStyle {
return BlockStyle(
margin = BoxBorders(
top = if (other.margin.top != 0.dp) other.margin.top else this.margin.top,
bottom = if (other.margin.bottom != 0.dp) other.margin.bottom else this.margin.bottom,
left = if (other.margin.left != 0.dp) other.margin.left else this.margin.left,
right = if (other.margin.right != 0.dp) other.margin.right else this.margin.right
),
padding = BoxBorders(
top = if (other.padding.top != 0.dp) other.padding.top else this.padding.top,
bottom = if (other.padding.bottom != 0.dp) other.padding.bottom else this.padding.bottom,
left = if (other.padding.left != 0.dp) other.padding.left else this.padding.left,
right = if (other.padding.right != 0.dp) other.padding.right else this.padding.right
),
width = if (other.width != Dp.Unspecified) other.width else this.width,
maxWidth = if (other.maxWidth != Dp.Unspecified) other.maxWidth else this.maxWidth,
height = if (other.height != Dp.Unspecified) other.height else this.height,
backgroundColor = if (other.backgroundColor.isSpecified) other.backgroundColor else this.backgroundColor,
border = other.border ?: this.border,
listStyleType = other.listStyleType ?: this.listStyleType,
listStyleImage = other.listStyleImage ?: this.listStyleImage,
pageBreakInsideAvoid = this.pageBreakInsideAvoid || other.pageBreakInsideAvoid,
pageBreakAfterAvoid = this.pageBreakAfterAvoid || other.pageBreakAfterAvoid,
boxSizing = other.boxSizing ?: this.boxSizing,
float = other.float ?: this.float,
clear = other.clear ?: this.clear,
position = other.position ?: this.position,
top = if (other.top.isSpecified) other.top else this.top,
right = if (other.right.isSpecified) other.right else this.right,
bottom = if (other.bottom.isSpecified) other.bottom else this.bottom,
left = if (other.left.isSpecified) other.left else this.left,
display = other.display ?: this.display,
flexDirection = other.flexDirection ?: this.flexDirection,
justifyContent = other.justifyContent ?: this.justifyContent,
alignItems = other.alignItems ?: this.alignItems,
horizontalAlign = other.horizontalAlign ?: this.horizontalAlign,
filter = other.filter ?: this.filter,
borderCollapse = other.borderCollapse ?: this.borderCollapse,
borderSpacing = if (other.borderSpacing != 0.dp) other.borderSpacing else this.borderSpacing,
borderRadius = if (other.borderRadius != 0.dp) other.borderRadius else this.borderRadius
)
}
}
@Serializable
data class BoxBorders(
@ProtoNumber(1) @Serializable(with = DpSerializer::class) val top: Dp = 0.dp,
@ProtoNumber(2) @Serializable(with = DpSerializer::class) val right: Dp = 0.dp,
@ProtoNumber(3) @Serializable(with = DpSerializer::class) val bottom: Dp = 0.dp,
@ProtoNumber(4) @Serializable(with = DpSerializer::class) val left: Dp = 0.dp
)
@Serializable
data class BorderStyle(
@ProtoNumber(1) @Serializable(with = DpSerializer::class) val width: Dp = 0.dp,
@ProtoNumber(2) @Serializable(with = ColorSerializer::class) val color: Color = Color.Transparent,
@ProtoNumber(3) val style: String = "solid"
)
@Serializable
sealed interface ContentBlock {
val style: BlockStyle
val elementId: String?
val cfi: String?
val blockIndex: Int
}
sealed interface TextContentBlock : ContentBlock {
val content: AnnotatedString
val startCharOffsetInSource: Int
val endCharOffsetInSource: Int
}
@Serializable
data class ParagraphBlock(
@ProtoNumber(1) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
@ProtoNumber(2) @Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(4) override val elementId: String? = null,
@ProtoNumber(5) override val cfi: String? = null,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) override val endCharOffsetInSource: Int = -1,
@ProtoNumber(8) override val blockIndex: Int
) : TextContentBlock
@Serializable
data class ImageBlock(
@ProtoNumber(1) val path: String,
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) val intrinsicWidth: Float? = null,
@ProtoNumber(4) val intrinsicHeight: Float? = null,
@ProtoNumber(5) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(6) override val elementId: String? = null,
@ProtoNumber(7) override val cfi: String? = null,
@ProtoNumber(8) val invertOnDarkTheme: Boolean = false,
@ProtoNumber(9) override val blockIndex: Int
) : ContentBlock
@Serializable
data class HeaderBlock(
@ProtoNumber(1) val level: Int,
@ProtoNumber(2) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
@ProtoNumber(3) @Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
@ProtoNumber(4) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(5) override val elementId: String? = null,
@ProtoNumber(6) override val cfi: String? = null,
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(8) override val endCharOffsetInSource: Int = -1,
@ProtoNumber(9) override val blockIndex: Int,
) : TextContentBlock
@Serializable
data class SpacerBlock(
@ProtoNumber(1) @Serializable(with = DpSerializer::class) val height: Dp = 8.dp,
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(3) override val elementId: String? = null,
@ProtoNumber(4) override val cfi: String? = null,
@ProtoNumber(5) override val blockIndex: Int
) : ContentBlock
@Serializable
data class QuoteBlock(
@ProtoNumber(1) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
@ProtoNumber(2) @Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(4) override val elementId: String? = null,
@ProtoNumber(5) override val cfi: String? = null,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) override val endCharOffsetInSource: Int = -1,
@ProtoNumber(8) override val blockIndex: Int
) : TextContentBlock
@Serializable
data class ListItemBlock(
@ProtoNumber(1) @Serializable(with = AnnotatedStringSerializer::class) override val content: AnnotatedString,
@ProtoNumber(2) val itemMarker: String?,
@ProtoNumber(3) val itemMarkerImage: String? = null,
@ProtoNumber(4) override val style: BlockStyle,
@ProtoNumber(5) override val elementId: String? = null,
@ProtoNumber(6) override val cfi: String? = null,
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(8) override val endCharOffsetInSource: Int = -1,
@ProtoNumber(9) override val blockIndex: Int
) : TextContentBlock
@Serializable
data class TableCell(
@ProtoNumber(1) val content: List<ContentBlock>,
@ProtoNumber(2) val isHeader: Boolean = false,
@ProtoNumber(3) val style: CssStyle = CssStyle(),
@ProtoNumber(4) val colspan: Int = 1
)
@Serializable
data class TableBlock(
@ProtoNumber(1) val rows: List<List<TableCell>>,
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(3) override val elementId: String? = null,
@ProtoNumber(4) override val cfi: String? = null,
@ProtoNumber(5) override val blockIndex: Int
) : ContentBlock
@Serializable
data class MathBlock(
@ProtoNumber(1) val svgContent: String?,
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) override val style: BlockStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) val svgWidth: String? = null,
@ProtoNumber(7) val svgHeight: String? = null,
@ProtoNumber(8) val svgViewBox: String? = null,
@ProtoNumber(9) val isFromMathJax: Boolean = false,
@ProtoNumber(10) override val blockIndex: Int
) : ContentBlock
@Serializable
data class WrappingContentBlock(
@ProtoNumber(1) val floatedImage: ImageBlock,
@ProtoNumber(2) val paragraphsToWrap: List<ParagraphBlock>,
@ProtoNumber(3) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(4) override val elementId: String? = null,
@ProtoNumber(5) override val cfi: String? = null,
@ProtoNumber(6) override val blockIndex: Int
) : ContentBlock
@Serializable
data class TextEmphasis(
@ProtoNumber(1) val style: String? = null,
@ProtoNumber(2) val fill: String? = null,
@ProtoNumber(3) @Serializable(with = ColorSerializer::class) val color: Color = Color.Unspecified,
@ProtoNumber(4) val position: String? = null
)
@Serializable
data class CssStyle(
@ProtoNumber(1) @Serializable(with = SpanStyleSerializer::class) val spanStyle: SpanStyle = SpanStyle(),
@ProtoNumber(2) @Serializable(with = ParagraphStyleSerializer::class) val paragraphStyle: ParagraphStyle = ParagraphStyle(),
@ProtoNumber(3) val blockStyle: BlockStyle = BlockStyle(),
@ProtoNumber(4) val fontFamilies: List<String> = emptyList(),
@ProtoNumber(5) val display: String? = null,
@ProtoNumber(6) @Serializable(with = TextUnitSerializer::class) val fontSize: TextUnit = TextUnit.Unspecified,
@ProtoNumber(7) val textTransform: String? = null,
@ProtoNumber(8) val boxSizing: String? = null,
@ProtoNumber(9) val content: String? = null,
@ProtoNumber(10) val hyphens: String? = null,
@ProtoNumber(11) val fontVariantNumeric: String? = null,
@ProtoNumber(12) val textEmphasis: TextEmphasis? = null
) {
fun merge(other: CssStyle): CssStyle {
return CssStyle(
spanStyle = this.spanStyle.merge(other.spanStyle),
paragraphStyle = this.paragraphStyle.merge(other.paragraphStyle),
blockStyle = this.blockStyle.merge(other.blockStyle),
fontFamilies = other.fontFamilies.takeIf { it.isNotEmpty() } ?: this.fontFamilies,
display = other.display ?: this.display,
fontSize = if (other.fontSize.isSpecified) other.fontSize else this.fontSize,
textTransform = other.textTransform ?: this.textTransform,
boxSizing = other.boxSizing ?: this.boxSizing,
content = other.content ?: this.content,
hyphens = other.hyphens ?: this.hyphens,
fontVariantNumeric = other.fontVariantNumeric ?: this.fontVariantNumeric,
textEmphasis = other.textEmphasis ?: this.textEmphasis
)
}
}
@Serializable
data class CssSelector(
@ProtoNumber(1) val selector: String,
@ProtoNumber(2) val specificity: Int
)
@Serializable
data class CssRule(
@ProtoNumber(1) val selector: CssSelector,
@ProtoNumber(2) val style: CssStyle
)
@Serializable
data class FontFaceInfo(
@ProtoNumber(1) val fontFamily: String,
@ProtoNumber(2) val src: String,
@ProtoNumber(3) @Serializable(with = com.aryan.reader.paginatedreader.serialization.FontWeightSerializer::class) val fontWeight: FontWeight?,
@ProtoNumber(4) @Serializable(with = com.aryan.reader.paginatedreader.serialization.FontStyleSerializer::class) val fontStyle: FontStyle?
)
@Serializable
data class Page(
@ProtoNumber(1) val content: List<ContentBlock>
)
@Serializable
data class FlexContainerBlock(
@ProtoNumber(1) val children: List<ContentBlock>,
@ProtoNumber(2) override val style: BlockStyle = BlockStyle(),
@ProtoNumber(3) override val elementId: String? = null,
@ProtoNumber(4) override val cfi: String? = null,
@ProtoNumber(5) override val blockIndex: Int
) : ContentBlock
@Serializable
data class OptimizedCssRules(
@ProtoNumber(1) val byTag: Map<String, List<CssRule>> = emptyMap(),
@ProtoNumber(2) val byClass: Map<String, List<CssRule>> = emptyMap(),
@ProtoNumber(3) val byId: Map<String, List<CssRule>> = emptyMap(),
@ProtoNumber(4) val otherComplex: List<CssRule> = emptyList()
) {
fun merge(other: OptimizedCssRules): OptimizedCssRules {
val mergedByTag = (this.byTag.asSequence() + other.byTag.asSequence())
.groupBy({ it.key }, { it.value })
.mapValues { (_, values) -> values.flatten() }
val mergedByClass = (this.byClass.asSequence() + other.byClass.asSequence())
.groupBy({ it.key }, { it.value })
.mapValues { (_, values) -> values.flatten() }
val mergedById = (this.byId.asSequence() + other.byId.asSequence())
.groupBy({ it.key }, { it.value })
.mapValues { (_, values) -> values.flatten() }
val mergedOtherComplex = this.otherComplex + other.otherComplex
return OptimizedCssRules(mergedByTag, mergedByClass, mergedById, mergedOtherComplex)
}
fun toFlatList(): List<CssRule> {
return byTag.values.flatten() + byClass.values.flatten() + byId.values.flatten() + otherComplex
}
}
data class OptimizedCssParseResult(
val rules: OptimizedCssRules,
val fontFaces: List<FontFaceInfo>
)

View file

@ -0,0 +1,149 @@
// PaginatedReaderViewModel.kt
package com.aryan.reader.paginatedreader
import android.content.Context
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
data class PaginatedReaderUiState(
val isLoading: Boolean = true,
val totalPageCount: Int = 0,
val generation: Int = 0
)
@OptIn(ExperimentalSerializationApi::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
class PaginatedReaderViewModel : ViewModel() {
@VisibleForTesting
internal var paginator: IPaginator? = null
private set
private val _uiState = MutableStateFlow(PaginatedReaderUiState())
val uiState: StateFlow<PaginatedReaderUiState> = _uiState.asStateFlow()
@VisibleForTesting
internal fun setPaginatorForTest(testPaginator: IPaginator) {
paginator = testPaginator
observePaginatorState()
}
companion object {
val proto = ProtoBuf { serializersModule = semanticBlockModule }
}
fun initialize(
book: EpubBook,
textMeasurer: TextMeasurer,
textConstraints: Constraints,
textStyle: TextStyle,
density: Density,
isDarkTheme: Boolean,
context: Context,
initialChapterToPaginate: Int?,
mathMLRenderer: MathMLRenderer
) {
if (paginator != null) return
viewModelScope.launch {
_uiState.value = _uiState.value.copy(isLoading = true)
// CSS Parsing and Font Loading
val userAgentStylesheet = UserAgentStylesheet.default
var allRules = OptimizedCssRules() // CHANGED from: mutableListOf<CssRule>()
val allFontFaces = mutableListOf<FontFaceInfo>()
val uaResult = CssParser.parse(
cssContent = userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
)
allRules = allRules.merge(uaResult.rules) // CHANGED
allFontFaces.addAll(uaResult.fontFaces)
book.css.forEach { (path, content) ->
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = textConstraints,
isDarkTheme = isDarkTheme
)
allRules = allRules.merge(bookCssResult.rules) // CHANGED
allFontFaces.addAll(bookCssResult.fontFaces)
}
val fontFamilyMap = loadFontFamilies(
fontFaces = allFontFaces,
extractionPath = book.extractionBasePath
)
val bookId = book.title
val bookCacheDao = BookCacheDatabase.getDatabase(context.applicationContext).bookCacheDao()
val newPaginator = BookPaginator(
coroutineScope = viewModelScope,
chapters = book.chaptersForPagination,
textMeasurer = textMeasurer,
constraints = textConstraints,
textStyle = textStyle,
extractionBasePath = book.extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
isDarkTheme = isDarkTheme,
bookId = bookId,
bookCacheDao = bookCacheDao,
proto = proto,
initialChapterToPaginate = initialChapterToPaginate ?: 0,
bookCss = book.css,
userAgentStylesheet = userAgentStylesheet,
allFontFaces = allFontFaces,
context = context.applicationContext,
mathMLRenderer = mathMLRenderer,
userTextAlign = null
)
paginator = newPaginator
observePaginatorState()
}
}
private fun observePaginatorState() {
val p = paginator ?: return
viewModelScope.launch {
snapshotFlow { p.isLoading }.collect {
_uiState.value = _uiState.value.copy(isLoading = it)
}
}
viewModelScope.launch {
snapshotFlow { p.totalPageCount }.collect {
_uiState.value = _uiState.value.copy(totalPageCount = it)
}
}
viewModelScope.launch {
snapshotFlow { p.generation }.collect {
_uiState.value = _uiState.value.copy(generation = it)
}
}
}
fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) {
paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete)
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,184 @@
// SemanticModel.kt
@file:OptIn(ExperimentalSerializationApi::class)
package com.aryan.reader.paginatedreader
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.protobuf.ProtoNumber
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import kotlinx.serialization.modules.subclass
@Serializable
sealed interface SemanticBlock {
val elementId: String?
val cfi: String?
val style: CssStyle
val blockIndex: Int
}
@Serializable
data class SemanticSpan(
@ProtoNumber(1) val start: Int,
@ProtoNumber(2) val end: Int,
@ProtoNumber(3) val style: CssStyle,
@ProtoNumber(4) val linkHref: String? = null,
@ProtoNumber(5) val tag: String,
@ProtoNumber(6) val elementId: String? = null // Add this
)
fun SemanticBlock.withElementId(id: String): SemanticBlock {
if (this.elementId != null) return this
return when (this) {
is SemanticParagraph -> this.copy(elementId = id)
is SemanticHeader -> this.copy(elementId = id)
is SemanticListItem -> this.copy(elementId = id)
is SemanticList -> this.copy(elementId = id)
is SemanticImage -> this.copy(elementId = id)
is SemanticMath -> this.copy(elementId = id)
is SemanticSpacer -> this.copy(elementId = id)
is SemanticTable -> this.copy(elementId = id)
is SemanticFlexContainer -> this.copy(elementId = id)
is SemanticWrappingBlock -> this.copy(elementId = id)
is SemanticTextBlock -> this
}
}
interface SemanticTextBlock : SemanticBlock {
val text: String
val spans: List<SemanticSpan>
val startCharOffsetInSource: Int
}
@Serializable
data class SemanticParagraph(
@ProtoNumber(1) override val text: String,
@ProtoNumber(2) override val spans: List<SemanticSpan>,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) override val blockIndex: Int = 0
) : SemanticTextBlock
@Serializable
data class SemanticHeader(
@ProtoNumber(1) val level: Int,
@ProtoNumber(2) override val text: String,
@ProtoNumber(3) override val spans: List<SemanticSpan>,
@ProtoNumber(4) override val style: CssStyle,
@ProtoNumber(5) override val elementId: String?,
@ProtoNumber(6) override val cfi: String?,
@ProtoNumber(7) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(8) override val blockIndex: Int = 0
) : SemanticTextBlock
@Serializable
data class SemanticListItem(
@ProtoNumber(1) override val text: String,
@ProtoNumber(2) override val spans: List<SemanticSpan>,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val startCharOffsetInSource: Int = 0,
@ProtoNumber(7) val itemMarkerImage: String?,
@ProtoNumber(8) override val blockIndex: Int = 0
) : SemanticTextBlock
@Serializable
data class SemanticList(
@ProtoNumber(1) val items: List<SemanticListItem>,
@ProtoNumber(2) val isOrdered: Boolean,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticImage(
@ProtoNumber(1) val path: String, // Will store the absolute path
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) val intrinsicWidth: Float?,
@ProtoNumber(4) val intrinsicHeight: Float?,
@ProtoNumber(5) override val style: CssStyle,
@ProtoNumber(6) override val elementId: String?,
@ProtoNumber(7) override val cfi: String?,
@ProtoNumber(8) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticMath(
@ProtoNumber(1) val svgContent: String?,
@ProtoNumber(2) val altText: String?,
@ProtoNumber(3) val svgWidth: String?,
@ProtoNumber(4) val svgHeight: String?,
@ProtoNumber(5) val svgViewBox: String?,
@ProtoNumber(6) val isFromMathJax: Boolean,
@ProtoNumber(7) override val style: CssStyle,
@ProtoNumber(8) override val elementId: String?,
@ProtoNumber(9) override val cfi: String?,
@ProtoNumber(10) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticSpacer(
@ProtoNumber(1) override val style: CssStyle,
@ProtoNumber(2) override val elementId: String?,
@ProtoNumber(3) override val cfi: String?,
@ProtoNumber(4) val isExplicitLineBreak: Boolean = false,
@ProtoNumber(5) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticTableCell(
@ProtoNumber(1) val content: List<SemanticBlock>,
@ProtoNumber(2) val isHeader: Boolean,
@ProtoNumber(3) val colspan: Int,
@ProtoNumber(4) val style: CssStyle
)
@Serializable
data class SemanticTable(
@ProtoNumber(1) val rows: List<List<SemanticTableCell>>,
@ProtoNumber(2) override val style: CssStyle,
@ProtoNumber(3) override val elementId: String?,
@ProtoNumber(4) override val cfi: String?,
@ProtoNumber(5) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticFlexContainer(
@ProtoNumber(1) val children: List<SemanticBlock>,
@ProtoNumber(2) override val style: CssStyle,
@ProtoNumber(3) override val elementId: String?,
@ProtoNumber(4) override val cfi: String?,
@ProtoNumber(5) override val blockIndex: Int = 0
) : SemanticBlock
@Serializable
data class SemanticWrappingBlock(
@ProtoNumber(1) val floatedImage: SemanticImage,
@ProtoNumber(2) val paragraphsToWrap: List<SemanticParagraph>,
@ProtoNumber(3) override val style: CssStyle,
@ProtoNumber(4) override val elementId: String?,
@ProtoNumber(5) override val cfi: String?,
@ProtoNumber(6) override val blockIndex: Int = 0
) : SemanticBlock
val semanticBlockModule = SerializersModule {
polymorphic(SemanticBlock::class) {
subclass(SemanticParagraph::class)
subclass(SemanticHeader::class)
subclass(SemanticListItem::class)
subclass(SemanticList::class)
subclass(SemanticImage::class)
subclass(SemanticMath::class)
subclass(SemanticSpacer::class)
subclass(SemanticTable::class)
subclass(SemanticFlexContainer::class)
subclass(SemanticWrappingBlock::class)
}
}

View file

@ -0,0 +1,81 @@
// StyleUtils.kt
package com.aryan.reader.paginatedreader
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
fun parseCssDimensionToTextUnit(
value: String,
containerWidthPx: Int,
density: Float
): TextUnit {
if (density <= 0) return TextUnit.Unspecified
val sanitizedValue = value.trim().lowercase()
return when {
sanitizedValue.endsWith("rem") -> sanitizedValue.removeSuffix("rem").toFloatOrNull()?.em ?: TextUnit.Unspecified
sanitizedValue.endsWith("em") -> sanitizedValue.removeSuffix("em").toFloatOrNull()?.em ?: TextUnit.Unspecified
sanitizedValue.endsWith("px") -> {
val px = sanitizedValue.removeSuffix("px").toFloatOrNull() ?: 0f
(px / density).sp
}
sanitizedValue.endsWith("pt") -> {
val pt = sanitizedValue.removeSuffix("pt").toFloatOrNull() ?: 0f
val px = pt * (4f / 3f)
(px / density).sp
}
sanitizedValue.endsWith("%") -> {
val percentage = sanitizedValue.removeSuffix("%").toFloatOrNull() ?: 0f
if (containerWidthPx > 0) {
val px = (percentage / 100f) * containerWidthPx
(px / density).sp
} else {
TextUnit.Unspecified
}
}
else -> TextUnit.Unspecified
}
}
fun parseCssSizeToDp(
value: String,
baseFontSizeSp: Float,
density: Float,
containerWidthPx: Int
): Dp {
if (density <= 0) return 0.dp
val sanitizedValue = value.trim().lowercase()
return when {
sanitizedValue.endsWith("px") -> {
val px = sanitizedValue.removeSuffix("px").toFloatOrNull() ?: 0f
(px / density).dp
}
sanitizedValue.endsWith("rem") -> {
val rem = sanitizedValue.removeSuffix("rem").toFloatOrNull() ?: 0f
(rem * baseFontSizeSp).dp
}
sanitizedValue.endsWith("em") -> {
val em = sanitizedValue.removeSuffix("em").toFloatOrNull() ?: 0f
(em * baseFontSizeSp).dp
}
sanitizedValue.endsWith("pt") -> {
val pt = sanitizedValue.removeSuffix("pt").toFloatOrNull() ?: 0f
val px = pt * (4f / 3f)
(px / density).dp
}
sanitizedValue.endsWith("%") -> {
val percentage = sanitizedValue.removeSuffix("%").toFloatOrNull() ?: 0f
if (containerWidthPx > 0) {
val px = (percentage / 100f) * containerWidthPx
(px / density).dp
} else {
0.dp
}
}
else -> 0.dp
}
}

View file

@ -0,0 +1,43 @@
// SvgStringFetcher.kt
package com.aryan.reader.paginatedreader
import timber.log.Timber
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.request.Options
import okio.Buffer
/**
* A custom data class to wrap raw SVG string content.
* This avoids conflicts with Coil's default String fetcher.
*/
data class SvgData(val content: String)
/**
* A custom Coil Fetcher that handles loading SVG data from our [SvgData] class.
*/
class SvgStringFetcher(
private val options: Options,
private val data: SvgData,
) : Fetcher {
override suspend fun fetch(): FetchResult {
Timber.d("SvgStringFetcher: fetching SVG data from SvgData object.")
val buffer = Buffer().writeUtf8(data.content)
return SourceResult(
source = ImageSource(buffer, options.context),
mimeType = "image/svg+xml",
dataSource = DataSource.MEMORY
)
}
class Factory : Fetcher.Factory<SvgData> {
override fun create(data: SvgData, options: Options, imageLoader: coil.ImageLoader): Fetcher {
Timber.d("SvgStringFetcher.Factory: create called for SvgData.")
return SvgStringFetcher(options, data)
}
}
}

View file

@ -0,0 +1,99 @@
// UserAgentStylesheet.kt
package com.aryan.reader.paginatedreader
object UserAgentStylesheet {
val default: String = """
/* Basic inline formatting */
b, strong {
font-weight: bold;
}
i, em, cite, dfn {
font-style: italic;
}
u {
text-decoration: underline;
}
s, strike, del {
text-decoration: line-through;
}
code, kbd, samp, tt, pre {
font-family: monospace;
}
/* Basic block elements */
h1 {
font-size: 2em;
font-weight: bold;
margin-top: 0.67em;
margin-bottom: 0.67em;
}
h2 {
font-size: 1.5em;
font-weight: bold;
margin-top: 0.83em;
margin-bottom: 0.83em;
}
h3 {
font-size: 1.17em;
font-weight: bold;
margin-top: 1em;
margin-bottom: 1em;
}
h4 {
font-size: 1em;
font-weight: bold;
margin-top: 1.33em;
margin-bottom: 1.33em;
}
h5 {
font-size: 0.83em;
font-weight: bold;
margin-top: 1.67em;
margin-bottom: 1.67em;
}
h6 {
font-size: 0.67em;
font-weight: bold;
margin-top: 2.33em;
margin-bottom: 2.33em;
}
p {
margin-top: 1em;
margin-bottom: 1em;
}
div {
margin-top: 0;
margin-bottom: 0;
}
blockquote {
margin-top: 1em;
margin-bottom: 1em;
margin-left: 40px;
margin-right: 40px;
}
dl {
margin-top: 1em;
margin-bottom: 1em;
}
dt {
font-weight: bold;
}
dd {
margin-left: 40px;
}
ul, ol {
margin-top: 1em;
margin-bottom: 1em;
padding-left: 40px;
}
li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
hr {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
""".trimIndent()
}

View file

@ -0,0 +1,16 @@
package com.aryan.reader.paginatedreader
object Woff2Converter {
init {
System.loadLibrary("native-lib")
}
/**
* Converts a WOFF2 font file into a TTF font file.
*
* @param woff2Data The raw byte array of the WOFF2 file.
* @return A byte array of the converted TTF file, or null if conversion fails.
*/
external fun convertWoff2ToTtf(woff2Data: ByteArray): ByteArray?
}

View file

@ -0,0 +1,178 @@
// BookCacheDatabase.kt
package com.aryan.reader.paginatedreader.data
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.Transaction
@Dao
abstract class BookCacheDao {
// --- Book Operations ---
@Query("SELECT * FROM processed_books WHERE bookId = :bookId")
abstract suspend fun getProcessedBook(bookId: String): ProcessedBook?
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertProcessedBook(book: ProcessedBook)
@Query("DELETE FROM processed_books WHERE bookId = :bookId")
abstract suspend fun deleteBook(bookId: String)
@Query("DELETE FROM processed_books")
abstract suspend fun clearProcessedBooks()
// --- Chapter Operations (Internal Raw Access) ---
@Query("SELECT * FROM processed_chapter_metadata WHERE book_id = :bookId AND chapter_index = :chapterIndex")
protected abstract suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata?
@Query("SELECT chunk_data FROM processed_chapter_chunks WHERE book_id = :bookId AND chapter_index = :chapterIndex ORDER BY chunk_index ASC")
protected abstract suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List<ByteArray>
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata)
@Insert(onConflict = OnConflictStrategy.REPLACE)
protected abstract suspend fun insertChapterChunks(chunks: List<ProcessedChapterChunk>)
@Query("DELETE FROM processed_chapter_metadata WHERE book_id = :bookId")
protected abstract suspend fun deleteChapterMetadataForBook(bookId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertAnchorIndices(anchors: List<AnchorIndexEntry>)
@Query("SELECT * FROM anchor_index WHERE bookId = :bookId AND anchorId = :anchorId LIMIT 1")
abstract suspend fun getAnchorIndex(bookId: String, anchorId: String): AnchorIndexEntry?
@Query("DELETE FROM anchor_index WHERE bookId = :bookId")
abstract suspend fun deleteAnchorsForBook(bookId: String)
@Transaction
open suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? {
val metadata = getChapterMetadata(bookId, chapterIndex) ?: return null
val chunks = getChapterChunks(bookId, chapterIndex)
if (chunks.isEmpty()) {
return ProcessedChapter(bookId, chapterIndex, ByteArray(0), metadata.estimatedPageCount)
}
val totalSize = chunks.sumOf { it.size }
val mergedData = ByteArray(totalSize)
var offset = 0
for (chunk in chunks) {
System.arraycopy(chunk, 0, mergedData, offset, chunk.size)
offset += chunk.size
}
return ProcessedChapter(
bookId = bookId,
chapterIndex = chapterIndex,
contentBlocksProto = mergedData,
estimatedPageCount = metadata.estimatedPageCount
)
}
@Transaction
open suspend fun insertProcessedChapters(chapters: List<ProcessedChapter>) {
@Suppress("LocalVariableName") val CHUNK_SIZE = 900 * 1024
for (chapter in chapters) {
val metadata = ProcessedChapterMetadata(
bookId = chapter.bookId,
chapterIndex = chapter.chapterIndex,
estimatedPageCount = chapter.estimatedPageCount
)
insertChapterMetadata(metadata)
val fullData = chapter.contentBlocksProto
if (fullData.isEmpty()) continue
val chunks = ArrayList<ProcessedChapterChunk>()
var offset = 0
var chunkIndex = 0
while (offset < fullData.size) {
val end = (offset + CHUNK_SIZE).coerceAtMost(fullData.size)
val chunkBytes = fullData.copyOfRange(offset, end)
chunks.add(
ProcessedChapterChunk(
bookId = chapter.bookId,
chapterIndex = chapter.chapterIndex,
chunkIndex = chunkIndex,
chunkData = chunkBytes
)
)
offset = end
chunkIndex++
}
insertChapterChunks(chunks)
}
}
@Transaction
open suspend fun deleteChaptersForBook(bookId: String) {
deleteChapterMetadataForBook(bookId)
}
@Transaction
open suspend fun clearProcessedChapters() {
deleteAllChapterMetadata()
}
@Query("DELETE FROM processed_chapter_metadata")
protected abstract suspend fun deleteAllChapterMetadata()
@Transaction
open suspend fun clearAllCache() {
clearProcessedBooks()
clearProcessedChapters()
}
@Query("SELECT * FROM configuration_cache WHERE bookId = :bookId AND configHash = :configHash")
abstract suspend fun getConfigurationCache(bookId: String, configHash: Int): ConfigurationCache?
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract suspend fun insertConfigurationCache(cache: ConfigurationCache)
}
@Database(
entities = [
ProcessedBook::class,
ProcessedChapterMetadata::class,
ProcessedChapterChunk::class,
ConfigurationCache::class,
AnchorIndexEntry::class
],
version = 6,
exportSchema = false
)
abstract class BookCacheDatabase : RoomDatabase() {
abstract fun bookCacheDao(): BookCacheDao
companion object {
@Volatile
private var INSTANCE: BookCacheDatabase? = null
fun getDatabase(context: Context): BookCacheDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
BookCacheDatabase::class.java,
"book_cache_database"
)
.fallbackToDestructiveMigration(true)
.build()
INSTANCE = instance
instance
}
}
}
}

View file

@ -0,0 +1,115 @@
// BookCacheEntities.kt
package com.aryan.reader.paginatedreader.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 6
@Entity(tableName = "processed_books")
data class ProcessedBook(
@PrimaryKey
val bookId: String,
val processingVersion: Int,
val totalPageCountEstimate: Int
)
@Entity(
tableName = "anchor_index",
primaryKeys = ["bookId", "anchorId"],
indices = [Index(value = ["bookId", "anchorId"])]
)
data class AnchorIndexEntry(
val bookId: String,
val anchorId: String,
val chapterIndex: Int,
val blockIndex: Int
)
data class ProcessedChapter(
val bookId: String,
val chapterIndex: Int,
val contentBlocksProto: ByteArray,
val estimatedPageCount: Int
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ProcessedChapter
if (bookId != other.bookId) return false
if (chapterIndex != other.chapterIndex) return false
if (!contentBlocksProto.contentEquals(other.contentBlocksProto)) return false
if (estimatedPageCount != other.estimatedPageCount) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + chapterIndex
result = 31 * result + contentBlocksProto.contentHashCode()
result = 31 * result + estimatedPageCount
return result
}
}
/**
* Database Entity: Stores metadata only (small size).
*/
@Entity(tableName = "processed_chapter_metadata", primaryKeys = ["book_id", "chapter_index"])
data class ProcessedChapterMetadata(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "estimated_page_count") val estimatedPageCount: Int
)
/**
* Database Entity: Stores the blob data in 1MB chunks to avoid CursorWindow limits.
*/
@Entity(
tableName = "processed_chapter_chunks",
primaryKeys = ["book_id", "chapter_index", "chunk_index"],
foreignKeys = [
ForeignKey(
entity = ProcessedChapterMetadata::class,
parentColumns = ["book_id", "chapter_index"],
childColumns = ["book_id", "chapter_index"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["book_id", "chapter_index"])]
)
data class ProcessedChapterChunk(
@ColumnInfo(name = "book_id") val bookId: String,
@ColumnInfo(name = "chapter_index") val chapterIndex: Int,
@ColumnInfo(name = "chunk_index") val chunkIndex: Int,
@ColumnInfo(name = "chunk_data", typeAffinity = ColumnInfo.BLOB) val chunkData: ByteArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as ProcessedChapterChunk
if (bookId != other.bookId) return false
if (chapterIndex != other.chapterIndex) return false
if (chunkIndex != other.chunkIndex) return false
if (!chunkData.contentEquals(other.chunkData)) return false
return true
}
override fun hashCode(): Int {
var result = bookId.hashCode()
result = 31 * result + chapterIndex
result = 31 * result + chunkIndex
result = 31 * result + chunkData.contentHashCode()
return result
}
}
@Entity(tableName = "configuration_cache", primaryKeys = ["bookId", "configHash"])
data class ConfigurationCache(
val bookId: String,
val configHash: Int,
val chapterPageCounts: String
)

View file

@ -0,0 +1,356 @@
// BookProcessingWorker.kt
package com.aryan.reader.paginatedreader.data
import android.content.Context
import android.graphics.BitmapFactory
import android.os.Build
import timber.log.Timber
import androidx.annotation.RequiresApi
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aryan.reader.paginatedreader.CssParser
import com.aryan.reader.paginatedreader.FontFaceInfo
import com.aryan.reader.paginatedreader.MathMLRenderer
import com.aryan.reader.paginatedreader.OptimizedCssRules
import com.aryan.reader.paginatedreader.RenderResult
import com.aryan.reader.paginatedreader.htmlToSemanticBlocks
import com.aryan.reader.paginatedreader.loadFontFamilies
import com.aryan.reader.paginatedreader.semanticBlockModule
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable
import kotlinx.serialization.decodeFromByteArray
import kotlinx.serialization.encodeToByteArray
import kotlinx.serialization.protobuf.ProtoBuf
import kotlinx.serialization.protobuf.ProtoNumber
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import java.io.File
import java.net.URLDecoder
import kotlin.math.abs
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class SerializableEpubChapter(
@ProtoNumber(1) val htmlContent: String,
@ProtoNumber(2) val title: String,
@ProtoNumber(3) val absPath: String
)
@OptIn(ExperimentalSerializationApi::class)
@Serializable
data class BookProcessingInput(
@ProtoNumber(1) val chapters: List<SerializableEpubChapter>,
@ProtoNumber(2) val userAgentStylesheet: String,
@ProtoNumber(3) val bookCss: Map<String, String>,
@ProtoNumber(4) val baseFontSizeSp: Float,
@ProtoNumber(5) val density: Float,
@ProtoNumber(6) val constraintsMaxWidth: Int,
@ProtoNumber(7) val constraintsMaxHeight: Int,
@ProtoNumber(8) val fontFaces: List<FontFaceInfo> = emptyList()
)
@OptIn(ExperimentalSerializationApi::class)
class BookProcessingWorker(
private val appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
companion object {
const val WORK_TAG = "book-processing"
private const val KEY_BOOK_ID = "bookId"
private const val KEY_EXTRACTION_BASE_PATH = "extractionBasePath"
private const val KEY_INPUT_FILE_PATH = "inputFilePath"
private const val KEY_ESTIMATED_TOTAL_PAGES = "estimatedTotalPages"
private const val KEY_START_CHAPTER_INDEX = "startChapterIndex"
fun enqueue(
context: Context,
bookId: String,
extractionBasePath: String,
estimatedTotalPages: Int,
processingInput: BookProcessingInput,
startChapterIndex: Int
) {
val tempFile = File.createTempFile("proc_input_", ".proto", context.cacheDir)
val proto = ProtoBuf { serializersModule = semanticBlockModule }
tempFile.writeBytes(proto.encodeToByteArray(processingInput))
val workData = Data.Builder()
.putString(KEY_BOOK_ID, bookId)
.putString(KEY_EXTRACTION_BASE_PATH, extractionBasePath)
.putInt(KEY_ESTIMATED_TOTAL_PAGES, estimatedTotalPages)
.putString(KEY_INPUT_FILE_PATH, tempFile.absolutePath)
.putInt(KEY_START_CHAPTER_INDEX, startChapterIndex)
.build()
val workRequest = OneTimeWorkRequestBuilder<BookProcessingWorker>()
.setInputData(workData)
.addTag(WORK_TAG)
.build()
WorkManager.getInstance(context).enqueueUniqueWork(
"process_$bookId",
androidx.work.ExistingWorkPolicy.KEEP,
workRequest
)
Timber.i("Enqueued background processing for book: $bookId")
}
}
private fun precalculateImageDimensions(
chapters: List<SerializableEpubChapter>,
extractionBasePath: String
): Map<String, Pair<Float, Float>> {
val dimensionsCache = mutableMapOf<String, Pair<Float, Float>>()
Timber.i("Starting pre-scan to calculate image dimensions...")
for (chapter in chapters) {
val document = Jsoup.parse(chapter.htmlContent)
val chapterParentPath = File(chapter.absPath).parent ?: ""
// Find all image tags (both <img> and <svg><image>)
document.select("img, image").forEach { element ->
val srcAttr = if (element.tagName() == "img") "src" else "href"
val src = element.attr(srcAttr).ifBlank { element.attr("xlink:href") }
if (src.isNotBlank()) {
val decodedSrc = try {
URLDecoder.decode(src, "UTF-8")
} catch (_: Exception) {
src
}
val imageFile = File(File(extractionBasePath, chapterParentPath), decodedSrc).canonicalFile
val imagePath = imageFile.absolutePath
// If not already cached, read dimensions from disk
if (imageFile.exists() && !dimensionsCache.containsKey(imagePath)) {
try {
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(imagePath, options)
if (options.outWidth > 0 && options.outHeight > 0) {
dimensionsCache[imagePath] = Pair(options.outWidth.toFloat(), options.outHeight.toFloat())
}
} catch (e: Exception) {
Timber.e(e, "Could not decode image bounds during pre-scan for $imagePath")
}
}
}
}
}
Timber.i("Pre-calculated dimensions for ${dimensionsCache.size} unique images.")
return dimensionsCache
}
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
val bookId = inputData.getString(KEY_BOOK_ID) ?: return@withContext Result.failure()
val extractionBasePath = inputData.getString(KEY_EXTRACTION_BASE_PATH) ?: return@withContext Result.failure()
val estimatedTotalPages = inputData.getInt(KEY_ESTIMATED_TOTAL_PAGES, 0)
val inputFilePath = inputData.getString(KEY_INPUT_FILE_PATH) ?: return@withContext Result.failure()
val startChapterIndex = inputData.getInt(KEY_START_CHAPTER_INDEX, 0)
val inputFile = File(inputFilePath)
if (!inputFile.exists()) return@withContext Result.failure()
val proto = ProtoBuf { serializersModule = semanticBlockModule }
val db = BookCacheDatabase.getDatabase(appContext)
val mathMLRenderer = MathMLRenderer(appContext)
try {
Timber.i("Worker starting for book: $bookId")
if (!mathMLRenderer.awaitReady()) {
Timber.e("MathMLRenderer failed to initialize. Aborting processing for this book.")
return@withContext Result.failure()
}
val input = proto.decodeFromByteArray<BookProcessingInput>(inputFile.readBytes())
Timber.i("Worker decoded input. Number of chapters received: ${input.chapters.size}")
// Worker now reconstructs everything it needs for a pure light-theme processing run.
val density = Density(input.density)
val constraints = Constraints(maxWidth = input.constraintsMaxWidth, maxHeight = input.constraintsMaxHeight)
val textStyle = TextStyle(color = Color.Black, fontSize = input.baseFontSizeSp.sp) // Hardcode light theme values
var lightThemeCssRules = OptimizedCssRules()
val uaResult = CssParser.parse(
cssContent = input.userAgentStylesheet,
cssPath = null,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
)
lightThemeCssRules = lightThemeCssRules.merge(uaResult.rules)
input.bookCss.forEach { (path, content) ->
val bookCssResult = CssParser.parse(
cssContent = content,
cssPath = path,
baseFontSizeSp = textStyle.fontSize.value,
density = density.density,
constraints = constraints,
isDarkTheme = false // GUARANTEED LIGHT THEME
)
lightThemeCssRules = lightThemeCssRules.merge(bookCssResult.rules)
}
val imageDimensionsCache = precalculateImageDimensions(input.chapters, extractionBasePath)
val fontFamilyMap = loadFontFamilies(input.fontFaces, extractionBasePath)
val numCores = (Runtime.getRuntime().availableProcessors() / 2).coerceIn(1, 4)
val chaptersToProcess = input.chapters.withIndex().toList()
.sortedBy { (index, _) -> abs(index - startChapterIndex) }
Timber.d("Preparing to process ${chaptersToProcess.size} chapters.")
Timber.i("Worker processing with up to $numCores threads, prioritizing around chapter $startChapterIndex.")
chaptersToProcess.chunked(numCores).forEach { chunk ->
Timber.d("Processing a chunk of ${chunk.size} chapters.")
val deferreds = chunk.map { (index, chapter) ->
async {
Timber.d("Async task started for chapter index $index.")
if (db.bookCacheDao().getProcessedChapter(bookId, index) == null) {
Timber.d("[BG_PROC] Caching chapter $index: ${chapter.title}")
val document = Jsoup.parse(chapter.htmlContent, chapter.absPath)
val mathElements = document.select("math")
val svgResults = mutableMapOf<String, String>()
if (mathElements.isNotEmpty()) {
Timber.d("Chapter $index (Background Worker): Found ${mathElements.size} MathML elements to process.")
mathElements.forEachIndexed { i, element ->
val uniqueId = "math-ch${index}-eq${i}"
val altText = element.attr("alttext").ifBlank { "Equation" }
val placeholder = Element("math-placeholder").attr("id", uniqueId)
when (val result = mathMLRenderer.render(element.outerHtml(), altText)) {
is RenderResult.Success -> {
Timber.d("Chapter $index (Background Worker): Render SUCCESS for $uniqueId")
val svgDoc = Jsoup.parse(result.svg)
val svgElement = svgDoc.selectFirst("svg")
val width = svgElement?.attr("width") ?: "N/A"
val height = svgElement?.attr("height") ?: "N/A"
val viewBox = svgElement?.attr("viewBox") ?: "N/A"
Timber.d("Worker received SVG for '$uniqueId'. width: $width, height: $height, viewBox: $viewBox, length: ${result.svg.length}")
svgResults[uniqueId] = result.svg
}
is RenderResult.Failure -> {
Timber.w("Chapter $index (Background Worker): Render FAILURE for $uniqueId. Alt: ${result.altText}")
placeholder.attr("alttext", result.altText)
}
}
element.replaceWith(placeholder)
}
Timber.d("Chapter $index (Background Worker): Finished processing MathML. SVG cache has ${svgResults.size} items. Keys: ${svgResults.keys.joinToString()}")
}
val processedHtml = document.outerHtml()
Timber.d("Chapter $index (Background Worker): Processed HTML contains <math-placeholder>: ${processedHtml.contains("math-placeholder")}")
val semanticBlocks = htmlToSemanticBlocks(
html = processedHtml,
cssRules = lightThemeCssRules,
textStyle = textStyle,
chapterAbsPath = chapter.absPath,
extractionBasePath = extractionBasePath,
density = density,
fontFamilyMap = fontFamilyMap,
constraints = constraints,
imageDimensionsCache = imageDimensionsCache,
mathSvgCache = svgResults
)
val protoBytes = proto.encodeToByteArray(semanticBlocks)
ProcessedChapter(
bookId = bookId,
chapterIndex = index,
contentBlocksProto = protoBytes,
estimatedPageCount = 0
)
} else {
Timber.d("Chapter $index was already in the database. Skipping.")
null
}
}
}
val processedChapters = deferreds.awaitAll().filterNotNull()
if (processedChapters.isNotEmpty()) {
db.bookCacheDao().insertProcessedChapters(processedChapters)
val allAnchors = mutableListOf<AnchorIndexEntry>()
processedChapters.forEach { chapter ->
val blocks = proto.decodeFromByteArray<List<com.aryan.reader.paginatedreader.SemanticBlock>>(chapter.contentBlocksProto)
allAnchors.addAll(extractAnchorsFromBlocks(bookId, chapter.chapterIndex, blocks))
}
if (allAnchors.isNotEmpty()) {
db.bookCacheDao().insertAnchorIndices(allAnchors)
Timber.tag("TOC_NAV_DEBUG").d("Indexed ${allAnchors.size} anchors for chapters in this batch.")
}
Timber.i("Worker cached a batch of ${processedChapters.size} chapters for book $bookId.")
}
}
val finalBookRecord = ProcessedBook(bookId, LATEST_PROCESSING_VERSION, estimatedTotalPages)
db.bookCacheDao().insertProcessedBook(finalBookRecord)
Timber.i("[BG_PROC] Finished processing all chapters for book $bookId.")
return@withContext Result.success()
} catch (e: Exception) {
Timber.e(e, "Error in pagination worker for book $bookId")
return@withContext Result.failure()
} finally {
inputFile.delete()
mathMLRenderer.destroy()
}
}
private fun extractAnchorsFromBlocks(
bookId: String,
chapterIndex: Int,
blocks: List<com.aryan.reader.paginatedreader.SemanticBlock>
): List<AnchorIndexEntry> {
val anchors = mutableListOf<AnchorIndexEntry>()
fun walk(block: com.aryan.reader.paginatedreader.SemanticBlock) {
// 1. Check block ID
block.elementId?.let {
anchors.add(AnchorIndexEntry(bookId, it, chapterIndex, block.blockIndex))
}
// 2. Check Span IDs (Inline anchors)
if (block is com.aryan.reader.paginatedreader.SemanticTextBlock) {
block.spans.forEach { span ->
span.elementId?.let {
anchors.add(AnchorIndexEntry(bookId, it, chapterIndex, block.blockIndex))
}
}
}
// 3. Recurse
when (block) {
is com.aryan.reader.paginatedreader.SemanticFlexContainer -> block.children.forEach { walk(it) }
is com.aryan.reader.paginatedreader.SemanticTable -> block.rows.flatten().forEach { cell -> cell.content.forEach { walk(it) } }
is com.aryan.reader.paginatedreader.SemanticList -> block.items.forEach { walk(it) }
is com.aryan.reader.paginatedreader.SemanticWrappingBlock -> {
walk(block.floatedImage)
block.paragraphsToWrap.forEach { walk(it) }
}
else -> {}
}
}
blocks.forEach { walk(it) }
return anchors
}
}

View file

@ -0,0 +1,495 @@
// ComposeTypeSerializers.kt
@file:OptIn(ExperimentalSerializationApi::class)
package com.aryan.reader.paginatedreader.serialization
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.text.font.FontFamily
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.BaselineShift
import androidx.compose.ui.text.style.Hyphens
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextIndent
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.TextUnitType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.isSpecified
import com.aryan.reader.paginatedreader.FontFamilyMapper
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.descriptors.element
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.encoding.decodeStructure
import kotlinx.serialization.encoding.encodeStructure
object ColorSerializer : KSerializer<Color> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Color") {
element<Long>("value")
}
override fun serialize(encoder: Encoder, value: Color) {
encoder.encodeStructure(descriptor) {
encodeLongElement(descriptor, 0, value.value.toLong())
}
}
override fun deserialize(decoder: Decoder): Color {
return decoder.decodeStructure(descriptor) {
var colorValue = 0L
while (true) {
when (val index = decodeElementIndex(descriptor)) {
0 -> colorValue = decodeLongElement(descriptor, 0)
-1 -> break
else -> error("Unexpected index: $index")
}
}
Color(colorValue.toULong())
}
}
}
object DpSerializer : KSerializer<Dp> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Dp") {
element<Float>("value")
}
override fun serialize(encoder: Encoder, value: Dp) {
encoder.encodeStructure(descriptor) {
if (value != Dp.Unspecified) {
encodeFloatElement(descriptor, 0, value.value)
}
}
}
override fun deserialize(decoder: Decoder): Dp {
return decoder.decodeStructure(descriptor) {
var dpValue: Float? = null
while (true) {
when (val index = decodeElementIndex(descriptor)) {
0 -> dpValue = decodeFloatElement(descriptor, 0)
-1 -> break
else -> error("Unexpected index: $index")
}
}
dpValue?.dp ?: Dp.Unspecified
}
}
}
object TextUnitTypeSerializer : KSerializer<TextUnitType> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextUnitType")
override fun serialize(encoder: Encoder, value: TextUnitType) {
val typeString = when (value) {
TextUnitType.Sp -> "Sp"
TextUnitType.Em -> "Em"
else -> "Unspecified"
}
encoder.encodeString(typeString)
}
override fun deserialize(decoder: Decoder): TextUnitType {
return when (decoder.decodeString()) {
"Sp" -> TextUnitType.Sp
"Em" -> TextUnitType.Em
else -> TextUnitType.Unspecified
}
}
}
@Serializable
@SerialName("TextUnit")
private data class TextUnitSurrogate(val value: Float, @Serializable(with = TextUnitTypeSerializer::class) val type: TextUnitType)
object TextUnitSerializer : KSerializer<TextUnit> {
override val descriptor: SerialDescriptor = TextUnitSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: TextUnit) {
if (value.isSpecified) {
val surrogate = TextUnitSurrogate(value.value, value.type)
encoder.encodeSerializableValue(TextUnitSurrogate.serializer(), surrogate)
}
}
override fun deserialize(decoder: Decoder): TextUnit {
return try {
val surrogate = decoder.decodeSerializableValue(TextUnitSurrogate.serializer())
TextUnit(surrogate.value, surrogate.type)
} catch (_: Exception) {
TextUnit.Unspecified
}
}
}
object FontWeightSerializer : KSerializer<FontWeight?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("FontWeight")
override fun serialize(encoder: Encoder, value: FontWeight?) = value?.let { encoder.encodeInt(it.weight) } ?: encoder.encodeNull()
override fun deserialize(decoder: Decoder): FontWeight? = if (decoder.decodeNotNullMark()) FontWeight(decoder.decodeInt()) else null
}
object FontStyleSerializer : KSerializer<FontStyle?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("FontStyle")
override fun serialize(encoder: Encoder, value: FontStyle?) {
val intValue = when (value) {
FontStyle.Normal -> 0
FontStyle.Italic -> 1
else -> -1
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): FontStyle? {
return when (decoder.decodeInt()) {
0 -> FontStyle.Normal
1 -> FontStyle.Italic
else -> null
}
}
}
object BaselineShiftSerializer : KSerializer<BaselineShift?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("BaselineShift")
override fun serialize(encoder: Encoder, value: BaselineShift?) = value?.let { encoder.encodeFloat(it.multiplier) } ?: encoder.encodeNull()
override fun deserialize(decoder: Decoder): BaselineShift? = if (decoder.decodeNotNullMark()) BaselineShift(decoder.decodeFloat()) else null
}
object TextDecorationSerializer : KSerializer<TextDecoration?> {
@Serializable
private data class TextDecorationSurrogate(val hasUnderline: Boolean, val hasLineThrough: Boolean)
override val descriptor: SerialDescriptor = TextDecorationSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: TextDecoration?) {
if (value == null) {
encoder.encodeNull()
return
}
val surrogate = TextDecorationSurrogate(
hasUnderline = value.contains(TextDecoration.Underline),
hasLineThrough = value.contains(TextDecoration.LineThrough)
)
encoder.encodeSerializableValue(TextDecorationSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): TextDecoration? {
if (decoder.decodeNotNullMark()) {
val surrogate = decoder.decodeSerializableValue(TextDecorationSurrogate.serializer())
var decoration: TextDecoration? = null
if (surrogate.hasUnderline) {
decoration = TextDecoration.Underline
}
if (surrogate.hasLineThrough) {
decoration = (decoration ?: TextDecoration.None) + TextDecoration.LineThrough
}
return decoration
}
return null
}
}
@Serializable
private data class ShadowSurrogate(
@Serializable(with = ColorSerializer::class) val color: Color,
val offsetX: Float,
val offsetY: Float,
val blurRadius: Float
)
object ShadowSerializer : KSerializer<Shadow?> {
override val descriptor: SerialDescriptor = ShadowSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: Shadow?) {
if (value == null) {
encoder.encodeNull()
return
}
val surrogate = ShadowSurrogate(value.color, value.offset.x, value.offset.y, value.blurRadius)
encoder.encodeSerializableValue(ShadowSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): Shadow? {
if (decoder.decodeNotNullMark()) {
val surrogate = decoder.decodeSerializableValue(ShadowSurrogate.serializer())
return Shadow(surrogate.color, Offset(surrogate.offsetX, surrogate.offsetY), surrogate.blurRadius)
}
return null
}
}
@Serializable
private data class SpanStyleSurrogate(
@Serializable(with = ColorSerializer::class) val color: Color = Color.Unspecified,
@Serializable(with = TextUnitSerializer::class) val fontSize: TextUnit = TextUnit.Unspecified,
@Serializable(with = FontWeightSerializer::class) val fontWeight: FontWeight? = null,
@Serializable(with = FontStyleSerializer::class) val fontStyle: FontStyle? = null,
@Serializable(with = FontFamilySerializer::class) val fontFamily: FontFamily? = null,
val fontFeatureSettings: String? = null,
@Serializable(with = TextUnitSerializer::class) val letterSpacing: TextUnit = TextUnit.Unspecified,
@Serializable(with = BaselineShiftSerializer::class) val baselineShift: BaselineShift? = null,
@Serializable(with = TextDecorationSerializer::class) val textDecoration: TextDecoration? = null,
@Serializable(with = ColorSerializer::class) val background: Color = Color.Unspecified,
@Serializable(with = ShadowSerializer::class) val shadow: Shadow? = null
)
object SpanStyleSerializer : KSerializer<SpanStyle> {
override val descriptor: SerialDescriptor = SpanStyleSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: SpanStyle) {
val surrogate = SpanStyleSurrogate(
color = value.color,
fontSize = value.fontSize,
fontWeight = value.fontWeight,
fontStyle = value.fontStyle,
fontFamily = value.fontFamily,
fontFeatureSettings = value.fontFeatureSettings,
letterSpacing = value.letterSpacing,
baselineShift = value.baselineShift,
textDecoration = value.textDecoration,
background = value.background,
shadow = value.shadow
)
encoder.encodeSerializableValue(SpanStyleSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): SpanStyle {
val surrogate = decoder.decodeSerializableValue(SpanStyleSurrogate.serializer())
return SpanStyle(
color = surrogate.color,
fontSize = surrogate.fontSize,
fontWeight = surrogate.fontWeight,
fontStyle = surrogate.fontStyle,
fontFamily = surrogate.fontFamily,
fontFeatureSettings = surrogate.fontFeatureSettings,
letterSpacing = surrogate.letterSpacing,
baselineShift = surrogate.baselineShift,
textDecoration = surrogate.textDecoration,
background = surrogate.background,
shadow = surrogate.shadow
)
}
}
object TextAlignSerializer : KSerializer<TextAlign?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextAlign")
override fun serialize(encoder: Encoder, value: TextAlign?) {
val intValue = when(value) {
TextAlign.Left -> 1
TextAlign.Right -> 2
TextAlign.Center -> 3
TextAlign.Justify -> 4
TextAlign.Start -> 5
TextAlign.End -> 6
else -> 0 // null or unspecified
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): TextAlign? {
return when(decoder.decodeInt()) {
1 -> TextAlign.Left
2 -> TextAlign.Right
3 -> TextAlign.Center
4 -> TextAlign.Justify
5 -> TextAlign.Start
6 -> TextAlign.End
else -> null
}
}
}
object TextDirectionSerializer : KSerializer<TextDirection?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("TextDirection")
override fun serialize(encoder: Encoder, value: TextDirection?) {
val intValue = when(value) {
TextDirection.Ltr -> 1
TextDirection.Rtl -> 2
TextDirection.Content -> 3
TextDirection.ContentOrLtr -> 4
TextDirection.ContentOrRtl -> 5
else -> 0 // null
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): TextDirection? {
return when(decoder.decodeInt()) {
1 -> TextDirection.Ltr
2 -> TextDirection.Rtl
3 -> TextDirection.Content
4 -> TextDirection.ContentOrLtr
5 -> TextDirection.ContentOrRtl
else -> null
}
}
}
object LineBreakSerializer : KSerializer<LineBreak?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("LineBreak")
override fun serialize(encoder: Encoder, value: LineBreak?) {
val intValue = when (value) {
LineBreak.Simple -> 1
LineBreak.Paragraph -> 2
else -> 0
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): LineBreak? {
return when(decoder.decodeInt()) {
1 -> LineBreak.Simple
2 -> LineBreak.Paragraph
else -> null
}
}
}
object HyphensSerializer : KSerializer<Hyphens?> {
override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Hyphens")
override fun serialize(encoder: Encoder, value: Hyphens?) {
val intValue = when(value) {
Hyphens.None -> 1
Hyphens.Auto -> 2
else -> 0 // null or unspecified
}
encoder.encodeInt(intValue)
}
override fun deserialize(decoder: Decoder): Hyphens? {
return when(decoder.decodeInt()) {
1 -> Hyphens.None
2 -> Hyphens.Auto
else -> null
}
}
}
@Serializable
private data class TextIndentSurrogate(
@Serializable(with = TextUnitSerializer::class) val firstLine: TextUnit,
@Serializable(with = TextUnitSerializer::class) val restLine: TextUnit
)
object TextIndentSerializer : KSerializer<TextIndent?> {
override val descriptor: SerialDescriptor = TextIndentSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: TextIndent?) {
if (value == null) {
encoder.encodeNull()
} else {
encoder.encodeSerializableValue(TextIndentSurrogate.serializer(), TextIndentSurrogate(value.firstLine, value.restLine))
}
}
override fun deserialize(decoder: Decoder): TextIndent? {
return if (decoder.decodeNotNullMark()) {
val surrogate = decoder.decodeSerializableValue(TextIndentSurrogate.serializer())
TextIndent(surrogate.firstLine, surrogate.restLine)
} else {
null
}
}
}
@Serializable
private data class ParagraphStyleSurrogate(
@Serializable(with = TextAlignSerializer::class) val textAlign: TextAlign? = null,
@Serializable(with = TextDirectionSerializer::class) val textDirection: TextDirection? = null,
@Serializable(with = TextUnitSerializer::class) val lineHeight: TextUnit = TextUnit.Unspecified,
@Serializable(with = TextIndentSerializer::class) val textIndent: TextIndent? = null,
@Serializable(with = LineBreakSerializer::class) val lineBreak: LineBreak? = null,
@Serializable(with = HyphensSerializer::class) val hyphens: Hyphens? = null
)
object ParagraphStyleSerializer : KSerializer<ParagraphStyle> {
override val descriptor: SerialDescriptor = ParagraphStyleSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: ParagraphStyle) {
val surrogate = ParagraphStyleSurrogate(
textAlign = value.textAlign,
textDirection = value.textDirection,
lineHeight = value.lineHeight,
textIndent = value.textIndent,
lineBreak = value.lineBreak,
hyphens = value.hyphens
)
encoder.encodeSerializableValue(ParagraphStyleSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): ParagraphStyle {
val surrogate = decoder.decodeSerializableValue(ParagraphStyleSurrogate.serializer())
return ParagraphStyle(
textAlign = surrogate.textAlign ?: TextAlign.Unspecified,
textDirection = surrogate.textDirection ?: TextDirection.Unspecified,
lineHeight = surrogate.lineHeight,
textIndent = surrogate.textIndent,
lineBreak = surrogate.lineBreak ?: LineBreak.Unspecified,
hyphens = surrogate.hyphens ?: Hyphens.Unspecified
)
}
}
object AnnotatedStringSerializer : KSerializer<AnnotatedString> {
@Serializable
private data class RangeSurrogate<T>(val item: T, val start: Int, val end: Int, val tag: String)
@Serializable
private data class AnnotatedStringSurrogate(
val text: String,
val spanStyles: List<RangeSurrogate<@Serializable(with = SpanStyleSerializer::class) SpanStyle>>,
val paragraphStyles: List<RangeSurrogate<@Serializable(with = ParagraphStyleSerializer::class) ParagraphStyle>>,
val stringAnnotations: List<RangeSurrogate<String>>
)
override val descriptor: SerialDescriptor = AnnotatedStringSurrogate.serializer().descriptor
override fun serialize(encoder: Encoder, value: AnnotatedString) {
val surrogate = AnnotatedStringSurrogate(
text = value.text,
spanStyles = value.spanStyles.map { RangeSurrogate(it.item, it.start, it.end, it.tag) },
paragraphStyles = value.paragraphStyles.map { RangeSurrogate(it.item, it.start, it.end, it.tag) },
stringAnnotations = value.getStringAnnotations(0, value.length).map { RangeSurrogate(it.item, it.start, it.end, it.tag) }
)
encoder.encodeSerializableValue(AnnotatedStringSurrogate.serializer(), surrogate)
}
override fun deserialize(decoder: Decoder): AnnotatedString {
val surrogate = decoder.decodeSerializableValue(AnnotatedStringSurrogate.serializer())
return AnnotatedString.Builder(surrogate.text).apply {
surrogate.spanStyles.forEach { addStyle(it.item, it.start, it.end) }
surrogate.paragraphStyles.forEach { addStyle(it.item, it.start, it.end) }
surrogate.stringAnnotations.forEach { addStringAnnotation(it.tag, it.item, it.start, it.end) }
}.toAnnotatedString()
}
}
object FontFamilySerializer : KSerializer<FontFamily?> {
override val descriptor = PrimitiveSerialDescriptor("FontFamily", PrimitiveKind.STRING)
override fun serialize(encoder: Encoder, value: FontFamily?) {
val name = FontFamilyMapper.fontFamilyToName(value ?: return encoder.encodeNull())
if (name != null) {
encoder.encodeString(name)
} else {
encoder.encodeNull()
}
}
override fun deserialize(decoder: Decoder): FontFamily? {
if (decoder.decodeNotNullMark()) {
return FontFamilyMapper.nameToFontFamily(decoder.decodeString())
}
return null
}
}

View file

@ -0,0 +1,275 @@
// AnnotationDock.kt
package com.aryan.reader.pdf
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Redo
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.aryan.reader.R
@Composable
fun AnnotationDock(
selectedTool: InkType,
activePenColor: Color,
activeHighlighterColor: Color,
onToolClick: (InkType) -> Unit,
onUndo: () -> Unit,
onRedo: () -> Unit,
onClose: () -> Unit,
canUndo: Boolean,
canRedo: Boolean,
lastPenTool: InkType,
modifier: Modifier = Modifier,
isSticky: Boolean = false,
isMinimized: Boolean,
onToggleMinimize: () -> Unit
) {
val showFullDock = isSticky || !isMinimized
val dockHeight = 56.dp
val buttonSize = 36.dp
val iconSize = 20.dp
val spacing = 8.dp
val horizontalPadding = 12.dp
if (showFullDock) {
val shape = if (isSticky) RectangleShape else RoundedCornerShape(percent = 50)
Surface(
color = Color(0xFF1E1E1E),
shape = shape,
shadowElevation = if (isSticky) 0.dp else 8.dp,
modifier = modifier.height(dockHeight)
) {
Row(
modifier = Modifier.padding(horizontal = horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(spacing)
) {
// Close Button
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.1f))
.clickable(onClick = onClose),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close Edit Mode",
tint = Color.White,
modifier = Modifier.size(iconSize)
)
}
val visIcon = if (isMinimized) Icons.Default.VisibilityOff else Icons.Default.Visibility
val visTint = Color.White
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.clickable(onClick = onToggleMinimize),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = visIcon,
contentDescription = "Toggle Visibility",
tint = visTint,
modifier = Modifier.size(iconSize)
)
}
// Vertical Divider
Box(
modifier = Modifier
.height(20.dp)
.width(1.dp)
.background(Color.White.copy(alpha = 0.2f))
)
val toolsAlpha = if (isMinimized) 0.3f else 1f
Row(
horizontalArrangement = Arrangement.spacedBy(spacing),
modifier = Modifier.alpha(toolsAlpha)
) {
// Pen Group
val isPenActive = !isMinimized && (selectedTool == InkType.PEN ||
selectedTool == InkType.FOUNTAIN_PEN ||
selectedTool == InkType.PENCIL)
DockIcon(
iconRes = R.drawable.pen,
isActive = isPenActive,
tintColor = if(isMinimized) Color.Gray else activePenColor,
description = "Pen",
size = buttonSize,
iconSize = iconSize,
onClick = { if(!isMinimized) onToolClick(lastPenTool) }
)
// Highlighter
val isHighlighterActive = !isMinimized && (selectedTool == InkType.HIGHLIGHTER || selectedTool == InkType.HIGHLIGHTER_ROUND)
DockIcon(
iconRes = R.drawable.marker,
isActive = isHighlighterActive,
tintColor = if(isMinimized) Color.Gray else activeHighlighterColor.copy(alpha = 1f),
description = "Highlighter",
size = buttonSize,
iconSize = iconSize,
onClick = {
if (!isMinimized) {
if (selectedTool != InkType.HIGHLIGHTER && selectedTool != InkType.HIGHLIGHTER_ROUND) {
onToolClick(InkType.HIGHLIGHTER)
} else {
onToolClick(selectedTool)
}
}
}
)
// Text Annotation
DockIcon(
iconRes = R.drawable.keyboard,
isActive = !isMinimized && selectedTool == InkType.TEXT,
tintColor = if(isMinimized) Color.Gray else Color.White,
description = "Text",
size = buttonSize,
iconSize = iconSize,
onClick = { if(!isMinimized) onToolClick(InkType.TEXT) }
)
// Eraser
DockIcon(
iconRes = R.drawable.eraser,
isActive = !isMinimized && selectedTool == InkType.ERASER,
tintColor = if(isMinimized) Color.Gray else Color.White,
description = "Eraser",
size = buttonSize,
iconSize = iconSize,
onClick = { if(!isMinimized) onToolClick(InkType.ERASER) }
)
}
Spacer(modifier = Modifier.weight(1f))
// Undo
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.clickable(enabled = canUndo && !isMinimized, onClick = onUndo),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Undo,
contentDescription = "Undo",
tint = if (canUndo && !isMinimized) Color.White else Color.White.copy(alpha = 0.3f),
modifier = Modifier.size(iconSize)
)
}
// Redo
Box(
modifier = Modifier
.size(buttonSize)
.clip(CircleShape)
.clickable(enabled = canRedo && !isMinimized, onClick = onRedo),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Redo,
contentDescription = "Redo",
tint = if (canRedo && !isMinimized) Color.White else Color.White.copy(alpha = 0.3f),
modifier = Modifier.size(iconSize)
)
}
}
}
} else {
// Minimized Floating State (Small Circle)
Surface(
color = Color(0xFF1E1E1E),
shape = CircleShape,
shadowElevation = 8.dp,
modifier = modifier.size(48.dp) // Reduced from 56dp
) {
Box(
modifier = Modifier.clickable(onClick = onToggleMinimize),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.VisibilityOff,
contentDescription = "Show Dock",
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
}
}
}
private fun Modifier.alpha(alpha: Float) = this.then(
Modifier.graphicsLayer { this.alpha = alpha }
)
@Composable
private fun DockIcon(
iconRes: Int,
isActive: Boolean,
tintColor: Color,
description: String,
size: androidx.compose.ui.unit.Dp,
iconSize: androidx.compose.ui.unit.Dp,
onClick: () -> Unit
) {
val backgroundAlpha = if (isActive) 0.15f else 0f
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(Color.White.copy(alpha = backgroundAlpha))
.semantics { this.selected = isActive }
.testTag("DockItem_$description")
.clickable(onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(id = iconRes),
contentDescription = description,
tint = tintColor,
modifier = Modifier.size(iconSize)
)
}
}

View file

@ -0,0 +1,204 @@
// DemoAnnotationGenerator.kt
package com.aryan.reader.pdf
import android.graphics.Path
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.PathParser
import com.aryan.reader.pdf.data.PdfAnnotation
object DemoAnnotationGenerator {
// --- SVG Configuration ---
private const val SVG_WIDTH = 800f
// Extracted from your Figma SVG
private val DECORATIVE_DOTS = listOf(
DotData(120f, 90f, 5f, Color(0xFFF59E0B), 0.7f),
DotData(680f, 210f, 6f, Color(0xFFEC4899), 0.7f),
DotData(700f, 110f, 4f, Color(0xFF8B5CF6), 0.7f),
DotData(150f, 230f, 5f, Color(0xFF10B981), 0.6f),
DotData(650f, 80f, 4f, Color(0xFFF59E0B), 0.6f),
DotData(90f, 180f, 3f, Color(0xFFEC4899), 0.5f),
DotData(720f, 170f, 5f, Color(0xFF8B5CF6), 0.6f)
)
private val TEXT_STROKES_DATA = listOf(
// T
"M 80 115 L 140 115 M 110 115 L 110 175 Q 110 185 115 185",
// r
"M 150 145 L 150 180 M 150 155 Q 155 145 165 145 Q 172 145 175 150",
// y (Improvised: Smoother curves and proper descender loop)
"M 182 148 Q 188 165 195 175 M 208 148 Q 200 165 195 175 L 192 195 Q 188 215 175 212 Q 165 208 172 198",
// " E" (Space included in coordinates)
"M 240 110 L 240 180 M 240 110 L 285 110 M 240 145 L 275 145 M 240 180 L 285 180",
// p
"M 305 145 L 305 215 M 305 158 Q 305 145 320 145 Q 340 145 345 160 Q 348 170 345 180 Q 340 195 320 195 Q 305 195 305 182",
// i
"M 365 145 L 365 180 M 365 130 L 365 132",
// s
"M 428 148 Q 418 143 408 145 Q 398 147 395 155 Q 393 162 400 165 Q 410 170 420 168 Q 428 166 430 172 Q 432 180 422 183 Q 412 186 402 182",
// t
"M 445 125 L 445 175 Q 445 185 455 185 Q 465 185 470 180 M 435 145 L 460 145",
// e (Fixed: Standard cursive loop instead of inverted shape)
"M 495 165 L 522 165 Q 522 145 508 145 Q 488 145 492 170 Q 495 190 525 185",
// m
"M 545 145 L 545 180 M 545 155 Q 545 145 555 145 Q 565 145 565 155 L 565 180 M 565 155 Q 565 145 575 145 Q 585 145 585 155 L 585 180",
// e (Fixed: Shifted +110 relative to previous 'e')
"M 605 165 L 632 165 Q 632 145 618 145 Q 598 145 602 170 Q 605 190 635 185",
// !
"M 660 125 L 660 165 M 660 178 L 660 182"
)
private const val UNDERLINE_DATA = "M 180 200 Q 400 220 620 200"
fun generateDemoAnnotations(pageIndex: Int): List<PdfAnnotation> {
val annotations = mutableListOf<PdfAnnotation>()
// --- Layout Calculation ---
// We want the SVG to occupy 80% of the page width, centered.
// PDF coordinates are 0..1.
val targetWidthPercent = 0.8f
// SVG aspect ratio 300 / 800 = 0.375
// Calculate scale factor relative to normalized page coordinates
val scaleX = targetWidthPercent / SVG_WIDTH
val scaleY = scaleX // Keep uniform scale in abstract space
// Center offsets (0.5 is middle of page)
val startX = (1f - targetWidthPercent) / 2f
val startY = 0.4f // Position slightly above center vertically
var currentTime = System.currentTimeMillis()
// Helper to transform SVG points to PDF Page Points
fun transformPoint(x: Float, y: Float): PdfPoint {
val pdfX = startX + (x * scaleX)
val pdfY = startY + (y * scaleY)
return PdfPoint(pdfX, pdfY, currentTime)
}
// 1. Render Decorative Dots
DECORATIVE_DOTS.forEach { dot ->
val pdfPoint = transformPoint(dot.cx, dot.cy)
// To make a "Dot" with the pen, we need at least 2 points very close together
// or a single point might not render depending on the implementation.
val points = listOf(
pdfPoint,
pdfPoint.copy(x = pdfPoint.x + 0.0001f, timestamp = currentTime + 10)
)
// Convert SVG radius to stroke width
val relativeThickness = (dot.r / SVG_WIDTH) * 2.5f
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN, // Standard pen for dots
pageIndex = pageIndex,
points = points,
color = dot.color.copy(alpha = dot.alpha),
strokeWidth = relativeThickness
)
)
currentTime += 50
}
// 2. Render Text ("Try Episteme!")
val textPaths = splitSvgPaths(TEXT_STROKES_DATA)
textPaths.forEach { pathString ->
val path = PathParser.createPathFromPathData(pathString)
val flattenedPoints = flattenPath(path)
if (flattenedPoints.isNotEmpty()) {
val pdfPoints = flattenedPoints.mapIndexed { _, p ->
// Increment time to simulate drawing speed for Fountain Pen physics
currentTime += 8
transformPoint(p.x, p.y).copy(timestamp = currentTime)
}
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.FOUNTAIN_PEN, // Handwriting looks best with this
pageIndex = pageIndex,
points = pdfPoints,
color = Color(0xFF418377), // Updated Green
strokeWidth = 0.004f // Fine tip
)
)
currentTime += 150 // Pen lift delay
}
}
// 3. Render Underline
val underlinePath = PathParser.createPathFromPathData(UNDERLINE_DATA)
val underlinePointsRaw = flattenPath(underlinePath)
val underlinePdfPoints = underlinePointsRaw.map { p ->
currentTime += 5
transformPoint(p.x, p.y).copy(timestamp = currentTime)
}
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN, // Consistent width for underline
pageIndex = pageIndex,
points = underlinePdfPoints,
color = Color(0xFFEC4899).copy(alpha = 0.6f), // Pink
strokeWidth = 0.005f
)
)
return annotations
}
// --- Helpers ---
private data class DotData(val cx: Float, val cy: Float, val r: Float, val color: Color, val alpha: Float)
private data class PointF(val x: Float, val y: Float)
/**
* Android's Path doesn't give us points directly. We use approximate().
*/
private fun flattenPath(path: Path): List<PointF> {
// Approximate the path with error tolerance 0.5 (pixels in SVG space)
val approximation = path.approximate(0.5f)
val points = mutableListOf<PointF>()
// approximation array format: [t0, x0, y0, t1, x1, y1, ...]
var i = 0
while (i < approximation.size) {
val x = approximation[i + 1]
val y = approximation[i + 2]
points.add(PointF(x, y))
i += 3
}
return points
}
/**
* The SVG string might contain separate letters, but even within a letter
* (like 'i' or 't') there might be a Move (M) command.
* We must split by 'M' to ensure we don't draw connecting lines where the pen should lift.
*/
private fun splitSvgPaths(@Suppress("SameParameterValue") rawPaths: List<String>): List<String> {
val result = mutableListOf<String>()
rawPaths.forEach { fullPathString ->
// Clean up and standardize
val cleanStr = fullPathString.trim()
// Split by "M" (Move command).
val parts = cleanStr.split("M")
parts.forEach { part ->
if (part.isNotBlank()) {
// Re-prepend M because split removed it
result.add("M ${part.trim()}")
}
}
}
return result
}
}

View file

@ -0,0 +1,211 @@
// MagnifierComposable.kt
package com.aryan.reader.pdf
import android.graphics.Rect
import timber.log.Timber
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
@Composable
fun MagnifierComposable(
sourceBitmap: ImageBitmap,
tiles: List<PdfTile>,
currentScale: Float,
magnifierCenterOnBitmap: Offset,
modifier: Modifier = Modifier,
magnifierWidth: Dp = 120.dp,
magnifierHeight: Dp = 60.dp,
zoomFactor: Float = 1.5f,
selectionRectsInBitmapCoords: List<Rect>,
highlightColor: Color,
colorFilter: ColorFilter? = null
) {
val stadiumShape = RoundedCornerShape(magnifierHeight / 2)
Box(
modifier = modifier
.width(magnifierWidth)
.height(magnifierHeight)
.shadow(4.dp, stadiumShape)
.clip(stadiumShape)
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val magnifierWidthPx = size.width
val magnifierHeightPx = size.height
Timber.d("Magnifier: START. scale=$currentScale, centerOnBitmap=$magnifierCenterOnBitmap")
val relevantTile = if (currentScale > 1f) {
tiles.find {
it.renderRect.contains(magnifierCenterOnBitmap.x.toInt(), magnifierCenterOnBitmap.y.toInt())
}
} else null
if (relevantTile != null) {
// --- HIGH-RES TILE PATH ---
Timber.d("Magnifier: Using HIGH-RES TILE path.")
Timber.d("Magnifier: Tile.renderRect=${relevantTile.renderRect}, Tile.bitmap.size=${relevantTile.bitmap.width}x${relevantTile.bitmap.height}")
val bitmapToUse = relevantTile.bitmap.asImageBitmap()
val tileBitmapWidth = relevantTile.bitmap.width.toFloat()
val tileRenderRectWidth = relevantTile.renderRect.width().toFloat()
val tileScale = if (tileRenderRectWidth > 0) {
tileBitmapWidth / tileRenderRectWidth
} else {
1f
}
Timber.d("Magnifier: Using derived tileScale=$tileScale instead of parent's currentScale=$currentScale")
val centerInTileBitmap = Offset(
x = (magnifierCenterOnBitmap.x - relevantTile.renderRect.left) * tileScale,
y = (magnifierCenterOnBitmap.y - relevantTile.renderRect.top) * tileScale
)
Timber.d("Magnifier: Calculated centerInTileBitmap=$centerInTileBitmap")
val sourceRectWidth = magnifierWidthPx / zoomFactor
val sourceRectHeight = magnifierHeightPx / zoomFactor
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
val srcLeft = (centerInTileBitmap.x - sourceRectWidth / 2f)
val srcTop = (centerInTileBitmap.y - sourceRectHeight / 2f)
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
val clampedSrcLeft = srcLeft.coerceIn(0f, bitmapToUse.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val clampedSrcTop = srcTop.coerceIn(0f, bitmapToUse.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
val finalSrcTopInt = clampedSrcTop.roundToInt()
val finalSrcWidthInt = (bitmapToUse.width - finalSrcLeftInt)
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
val finalSrcHeightInt = (bitmapToUse.height - finalSrcTopInt)
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
Timber.d("Magnifier: Final source rect to draw from tile: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= bitmapToUse.width || finalSrcTopInt >= bitmapToUse.height) {
Timber.w("Magnifier: Final source rect is invalid, returning.")
return@Canvas
}
drawImage(
image = bitmapToUse,
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
colorFilter = colorFilter
)
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
val translatedLeft = (rectInBitmap.left - relevantTile.renderRect.left) * tileScale
val translatedTop = (rectInBitmap.top - relevantTile.renderRect.top) * tileScale
val translatedRight = (rectInBitmap.right - relevantTile.renderRect.left) * tileScale
val translatedBottom = (rectInBitmap.bottom - relevantTile.renderRect.top) * tileScale
val finalLeft = translatedLeft - clampedSrcLeft
val finalTop = translatedTop - clampedSrcTop
val finalRight = translatedRight - clampedSrcLeft
val finalBottom = translatedBottom - clampedSrcTop
val magnifiedLeft = finalLeft * zoomFactor
val magnifiedTop = finalTop * zoomFactor
val magnifiedRight = finalRight * zoomFactor
val magnifiedBottom = finalBottom * zoomFactor
if (magnifiedRight > 0 && magnifiedLeft < magnifierWidthPx && magnifiedBottom > 0 && magnifiedTop < magnifierHeightPx) {
drawRect(
color = highlightColor,
topLeft = Offset(magnifiedLeft, magnifiedTop),
size = androidx.compose.ui.geometry.Size(
width = magnifiedRight - magnifiedLeft,
height = magnifiedBottom - magnifiedTop
)
)
}
}
} else {
// --- LOW-RES / NO-ZOOM PATH ---
Timber.d("Magnifier: Using LOW-RES (base bitmap) path.")
val sourceRectWidth = magnifierWidthPx / zoomFactor
val sourceRectHeight = magnifierHeightPx / zoomFactor
Timber.d("Magnifier: Desired sourceRect size=${sourceRectWidth}x$sourceRectHeight")
val srcLeft = (magnifierCenterOnBitmap.x - sourceRectWidth / 2f)
val srcTop = (magnifierCenterOnBitmap.y - sourceRectHeight / 2f)
Timber.d("Magnifier: Calculated source top-left=($srcLeft, $srcTop)")
val clampedSrcLeft = srcLeft.coerceIn(0f, sourceBitmap.width.toFloat() - sourceRectWidth.coerceAtLeast(1f))
val clampedSrcTop = srcTop.coerceIn(0f, sourceBitmap.height.toFloat() - sourceRectHeight.coerceAtLeast(1f))
Timber.d("Magnifier: Clamped source top-left=($clampedSrcLeft, $clampedSrcTop)")
val finalSrcLeftInt = clampedSrcLeft.roundToInt()
val finalSrcTopInt = clampedSrcTop.roundToInt()
val finalSrcWidthInt = (sourceBitmap.width - finalSrcLeftInt)
.coerceAtMost(sourceRectWidth.roundToInt()).coerceAtLeast(1)
val finalSrcHeightInt = (sourceBitmap.height - finalSrcTopInt)
.coerceAtMost(sourceRectHeight.roundToInt()).coerceAtLeast(1)
Timber.d("Magnifier: Final source rect to draw from base: offset=($finalSrcLeftInt, $finalSrcTopInt), size=${finalSrcWidthInt}x$finalSrcHeightInt")
if (finalSrcWidthInt <= 0 || finalSrcHeightInt <= 0 || finalSrcLeftInt >= sourceBitmap.width || finalSrcTopInt >= sourceBitmap.height) {
Timber.w("Magnifier: Final source rect is invalid, returning.")
return@Canvas
}
drawImage(
image = sourceBitmap,
srcOffset = IntOffset(finalSrcLeftInt, finalSrcTopInt),
srcSize = IntSize(finalSrcWidthInt, finalSrcHeightInt),
dstSize = IntSize(magnifierWidthPx.roundToInt(), magnifierHeightPx.roundToInt()),
colorFilter = colorFilter
)
selectionRectsInBitmapCoords.forEach { rectInBitmap ->
val translatedLeft = rectInBitmap.left - clampedSrcLeft
val translatedTop = rectInBitmap.top - clampedSrcTop
val rectWidthInBitmap = rectInBitmap.width().toFloat()
val rectHeightInBitmap = rectInBitmap.height().toFloat()
val magnifiedLeft = translatedLeft * zoomFactor
val magnifiedTop = translatedTop * zoomFactor
val magnifiedWidth = rectWidthInBitmap * zoomFactor
val magnifiedHeight = rectHeightInBitmap * zoomFactor
if (magnifiedLeft + magnifiedWidth > 0 && magnifiedLeft < magnifierWidthPx &&
magnifiedTop + magnifiedHeight > 0 && magnifiedTop < magnifierHeightPx) {
drawRect(
color = highlightColor,
topLeft = Offset(magnifiedLeft, magnifiedTop),
size = androidx.compose.ui.geometry.Size(
width = magnifiedWidth,
height = magnifiedHeight
)
)
}
}
}
}
}
}

View file

@ -0,0 +1,34 @@
package com.aryan.reader.pdf.ocr
import android.graphics.Rect
/**
* Platform-agnostic OCR result models to decouple the app from Google ML Kit.
*/
data class OcrResult(
val text: String,
val textBlocks: List<OcrBlock>
)
data class OcrBlock(
val text: String,
val boundingBox: Rect?,
val lines: List<OcrLine>
)
data class OcrLine(
val text: String,
val boundingBox: Rect?,
val elements: List<OcrElement>
)
data class OcrElement(
val text: String,
val boundingBox: Rect?,
val symbols: List<OcrSymbol>
)
data class OcrSymbol(
val text: String,
val boundingBox: Rect?
)

View file

@ -0,0 +1,73 @@
// PdfCoverGenerator.kt
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import timber.log.Timber
import androidx.core.graphics.createBitmap
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private const val TAG = "PdfCoverGenerator"
class PdfCoverGenerator(context: Context) {
private val appContext = context.applicationContext
private val pdfiumCore = PdfiumCoreKt(Dispatchers.IO)
/**
* Generates a Bitmap cover for the first page of a PDF.
* This function is safe to call from any thread and performs its work on Dispatchers.IO.
*
* @param pdfUri The Uri of the PDF file.
* @param targetHeight The desired height of the output Bitmap. Width is scaled proportionally.
* @return A Bitmap of the first page, or null if an error occurs.
*/
suspend fun generateCover(pdfUri: Uri, targetHeight: Int = 800): Bitmap? {
return withContext(Dispatchers.IO) {
try {
appContext.contentResolver.openFileDescriptor(pdfUri, "r").use { pfd ->
if (pfd == null) {
Timber.e("Failed to open ParcelFileDescriptor for URI: $pdfUri")
return@withContext null
}
pdfiumCore.newDocument(pfd).use { doc ->
if (doc.getPageCount() == 0) {
Timber.w("PDF has no pages, cannot generate cover: $pdfUri")
return@withContext null
}
doc.openPage(0).use { page ->
val originalWidth = page.getPageWidthPoint()
val originalHeight = page.getPageHeightPoint()
if (originalWidth <= 0 || originalHeight <= 0) {
Timber.e("Invalid page dimensions for cover: $pdfUri")
return@withContext null
}
val aspectRatio = originalWidth.toFloat() / originalHeight.toFloat()
val targetWidth = (targetHeight * aspectRatio).toInt()
if (targetWidth <= 0) {
Timber.e("Calculated invalid bitmap width for cover: $targetWidth")
return@withContext null
}
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(
bitmap = bitmap,
startX = 0, startY = 0,
drawSizeX = targetWidth, drawSizeY = targetHeight,
renderAnnot = false
)
bitmap
}
}
}
} catch (e: Exception) {
Timber.e(e, "Error generating PDF cover for URI: $pdfUri")
null
}
}
}
}

View file

@ -0,0 +1,993 @@
// PdfExporter.kt
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Shader
import android.net.Uri
import androidx.compose.ui.graphics.Color
import com.tom_roush.pdfbox.pdmodel.font.PDType0Font
import java.io.File
import java.io.FileInputStream
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.isSpecified
import androidx.core.graphics.createBitmap
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import com.tom_roush.pdfbox.pdmodel.PDDocument
import com.tom_roush.pdfbox.pdmodel.PDPage
import com.tom_roush.pdfbox.pdmodel.PDPageContentStream
import com.tom_roush.pdfbox.pdmodel.common.PDRectangle
import com.tom_roush.pdfbox.pdmodel.font.PDFont
import com.tom_roush.pdfbox.pdmodel.font.PDType1Font
import com.tom_roush.pdfbox.pdmodel.graphics.blend.BlendMode
import com.tom_roush.pdfbox.pdmodel.graphics.image.LosslessFactory
import com.tom_roush.pdfbox.pdmodel.graphics.state.PDExtendedGraphicsState
import com.tom_roush.pdfbox.pdmodel.graphics.state.RenderingMode
import com.tom_roush.pdfbox.util.Matrix
import java.io.OutputStream
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.StringTokenizer
object PdfExporter {
private class PdfBoxFontCache(val doc: PDDocument, val context: Context) {
private val cache = mutableMapOf<String, PDFont>()
fun getFont(fontPath: String?, fontName: String?, isBold: Boolean, isItalic: Boolean): PDFont {
if (!fontPath.isNullOrBlank()) {
Timber.tag("PdfFontDebug").d("Exporter: Requesting font at $fontPath")
val cached = cache[fontPath]
if (cached != null) return cached
try {
val font = if (fontPath.startsWith("asset:")) {
val assetPath = fontPath.removePrefix("asset:")
Timber.tag("PdfFontDebug").i("Exporter: Loading preset font from assets: $assetPath")
PDType0Font.load(doc, context.assets.open(assetPath))
} else {
val file = File(fontPath)
if (file.exists()) {
PDType0Font.load(doc, FileInputStream(file))
} else null
}
if (font != null) {
cache[fontPath] = font
return font
}
} catch (e: Exception) {
Timber.tag("PdfFontDebug").e(e, "Exporter: Failed to embed $fontPath")
}
}
// 2. Map Standard Presets via fontName
if (fontName != null) {
when (fontName) {
"Serif" -> return when {
isBold && isItalic -> PDType1Font.TIMES_BOLD_ITALIC
isBold -> PDType1Font.TIMES_BOLD
isItalic -> PDType1Font.TIMES_ITALIC
else -> PDType1Font.TIMES_ROMAN
}
"Monospace" -> return when {
isBold && isItalic -> PDType1Font.COURIER_BOLD_OBLIQUE
isBold -> PDType1Font.COURIER_BOLD
isItalic -> PDType1Font.COURIER_OBLIQUE
else -> PDType1Font.COURIER
}
// "Sans" and others fall through to Helvetica
}
}
// 3. Fallback to Helvetica (Sans-Serif)
return when {
isBold && isItalic -> PDType1Font.HELVETICA_BOLD_OBLIQUE
isBold -> PDType1Font.HELVETICA_BOLD
isItalic -> PDType1Font.HELVETICA_OBLIQUE
else -> PDType1Font.HELVETICA
}
}
}
private fun applyStyleSimulations(
cs: PDPageContentStream,
fontSize: Float,
isBold: Boolean,
isItalic: Boolean,
isCustomFont: Boolean,
x: Float,
y: Float
) {
if (isCustomFont) {
if (isBold) {
cs.setRenderingMode(RenderingMode.FILL_STROKE)
cs.setLineWidth(fontSize * 0.03f)
} else {
cs.setRenderingMode(RenderingMode.FILL)
}
if (isItalic) {
cs.setTextMatrix(Matrix(1f, 0f, 0.3f, 1f, x, y))
} else {
cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, x, y))
}
} else {
cs.setRenderingMode(RenderingMode.FILL)
cs.setTextMatrix(Matrix(1f, 0f, 0f, 1f, x, y))
}
}
suspend fun exportAnnotatedPdf(
context: Context,
sourceUri: Uri,
destStream: OutputStream,
virtualPages: List<VirtualPage>?,
inkAnnotations: Map<Int, List<PdfAnnotation>>,
richTextPageLayouts: List<PageTextLayout>? = null,
textBoxes: List<PdfTextBox>? = null
) {
withContext(Dispatchers.IO) {
var sourceDocument: PDDocument? = null
var destDocument: PDDocument? = null
try {
val inputStream = context.contentResolver.openInputStream(sourceUri)
sourceDocument = PDDocument.load(inputStream)
destDocument = PDDocument()
// Determine the sequence of pages to export
val pagesToProcess: List<VirtualPage> =
virtualPages
?: (0 until sourceDocument.numberOfPages).map {
VirtualPage.PdfPage(it)
}
val referencePage =
if (sourceDocument.numberOfPages > 0) sourceDocument.getPage(0) else null
val fontCache = PdfBoxFontCache(destDocument, context)
pagesToProcess.forEachIndexed { virtualIndex, vPage ->
val pageToDecorate: PDPage =
when (vPage) {
is VirtualPage.PdfPage -> {
if (vPage.pdfIndex < sourceDocument.numberOfPages) {
destDocument.importPage(
sourceDocument.getPage(vPage.pdfIndex)
)
} else {
Timber.w(
"Source page ${vPage.pdfIndex} is out of bounds! Creating blank page as fallback."
)
val blank =
PDPage(referencePage?.mediaBox ?: PDRectangle.A4)
destDocument.addPage(blank)
blank
}
}
is VirtualPage.BlankPage -> {
Timber.tag("PdfExportSize").d("Creating blank page with explicit dimensions: ${vPage.width}x${vPage.height}")
val blank = PDPage(PDRectangle(vPage.width.toFloat(), vPage.height.toFloat()))
destDocument.addPage(blank)
blank
}
}
val pageInkAnnos = inkAnnotations[virtualIndex] ?: emptyList()
val richTextLayout = richTextPageLayouts?.find { it.pageIndex == virtualIndex }
val cropBox = pageToDecorate.cropBox
val pageWidth = cropBox.width
val pageHeight = cropBox.height
val lowerLeftY = cropBox.lowerLeftY
if (pageInkAnnos.isNotEmpty()) {
val (pencilAnnos, vectorAnnos) =
pageInkAnnos.partition { it.inkType == InkType.PENCIL }
if (pencilAnnos.isNotEmpty()) {
drawPencilOverlay(
destDocument,
pageToDecorate,
pencilAnnos,
pageWidth,
pageHeight,
lowerLeftY
)
}
if (vectorAnnos.isNotEmpty()) {
PDPageContentStream(
destDocument,
pageToDecorate,
PDPageContentStream.AppendMode.APPEND,
true,
true
)
.use { cs ->
vectorAnnos.forEach { annotation ->
if (annotation.inkType == InkType.FOUNTAIN_PEN) {
drawFountainPen(
cs,
annotation,
pageWidth,
pageHeight,
lowerLeftY
)
} else {
drawStandardAnnotation(
cs,
annotation,
pageWidth,
pageHeight,
lowerLeftY
)
}
}
}
}
}
if (richTextLayout != null && richTextLayout.visibleText.isNotEmpty()) {
PDPageContentStream(destDocument, pageToDecorate, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
drawRichTextLayout(cs, richTextLayout, pageWidth, pageHeight, lowerLeftY, fontCache)
}
}
val pageTextBoxes = textBoxes?.filter { it.pageIndex == virtualIndex }
if (!pageTextBoxes.isNullOrEmpty()) {
PDPageContentStream(destDocument, pageToDecorate, PDPageContentStream.AppendMode.APPEND, true, true).use { cs ->
drawTextBoxes(cs, pageTextBoxes, pageWidth, pageHeight, lowerLeftY, fontCache)
}
}
}
destDocument.save(destStream)
} catch (e: Exception) {
Timber.e(e, "Export failed")
throw e
} finally {
sourceDocument?.close()
destDocument?.close()
destStream.close()
}
}
}
private fun drawTextBoxes(
cs: PDPageContentStream,
boxes: List<PdfTextBox>,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float,
fontCache: PdfBoxFontCache
) {
for (box in boxes) {
if (box.text.isBlank()) continue
val font = fontCache.getFont(box.fontPath, box.fontName, box.isBold, box.isItalic)
val fontSize = box.fontSize * pageHeight
val lineHeight = fontSize * 1.2f
val boxX = box.relativeBounds.left * pageWidth
val boxWidth = box.relativeBounds.width * pageWidth
val topY = lowerLeftY + pageHeight - (box.relativeBounds.top * pageHeight)
val wrappedLines = mutableListOf<String>()
val paragraphs = box.text.split('\n')
for (paragraph in paragraphs) {
if (paragraph.isEmpty()) {
wrappedLines.add("")
continue
}
val tokenizer = StringTokenizer(paragraph, " ", true)
var currentLine = StringBuilder()
var currentLineWidth = 0f
while (tokenizer.hasMoreTokens()) {
val token = tokenizer.nextToken()
fun getStringWidth(s: String): Float = try {
(font.getStringWidth(s) / 1000f) * fontSize
} catch (_: Exception) { 0f }
val tokenWidth = getStringWidth(token)
if (tokenWidth > boxWidth) {
if (currentLine.isNotEmpty()) {
wrappedLines.add(currentLine.toString())
currentLine = StringBuilder()
currentLineWidth = 0f
}
var tempWord = StringBuilder()
var tempWidth = 0f
for (char in token) {
val charW = getStringWidth(char.toString())
if (tempWidth + charW > boxWidth) {
wrappedLines.add(tempWord.toString())
tempWord = StringBuilder(char.toString())
tempWidth = charW
} else {
tempWord.append(char)
tempWidth += charW
}
}
currentLine.append(tempWord)
currentLineWidth = tempWidth
} else if (currentLineWidth + tokenWidth <= boxWidth) {
currentLine.append(token)
currentLineWidth += tokenWidth
} else {
wrappedLines.add(currentLine.toString())
if (token.isBlank()) {
currentLine = StringBuilder()
currentLineWidth = 0f
} else {
currentLine = StringBuilder(token)
currentLineWidth = tokenWidth
}
}
}
if (currentLine.isNotEmpty()) {
wrappedLines.add(currentLine.toString())
}
}
if (box.backgroundColor != Color.Transparent &&
box.backgroundColor != Color.Unspecified) {
val r = box.backgroundColor.red
val g = box.backgroundColor.green
val b = box.backgroundColor.blue
val a = box.backgroundColor.alpha
if (a < 1.0f) {
val gs = PDExtendedGraphicsState()
gs.nonStrokingAlphaConstant = a
cs.setGraphicsStateParameters(gs)
}
cs.setNonStrokingColor(r, g, b)
var currentBgY = topY
for (line in wrappedLines) {
if (line.isNotEmpty()) {
val lineWidth = try { (font.getStringWidth(line) / 1000f) * fontSize } catch(_: Exception) { 0f }
val padding = fontSize * 0.1f
cs.addRect(boxX - padding, currentBgY - lineHeight, lineWidth + (padding * 2), lineHeight)
cs.fill()
}
currentBgY -= lineHeight
}
if (a < 1.0f) {
val gs = PDExtendedGraphicsState()
gs.nonStrokingAlphaConstant = 1.0f
cs.setGraphicsStateParameters(gs)
}
}
val tr = box.color.red
val tg = box.color.green
val tb = box.color.blue
cs.setNonStrokingColor(tr, tg, tb)
cs.setFont(font, fontSize)
val textY = topY - (fontSize * 0.85f)
cs.beginText()
for ((index, line) in wrappedLines.withIndex()) {
val currentLineY = textY - (index * lineHeight)
applyStyleSimulations(
cs = cs,
fontSize = fontSize,
isBold = box.isBold,
isItalic = box.isItalic,
isCustomFont = !box.fontPath.isNullOrBlank(),
x = boxX,
y = currentLineY
)
if (line.isNotEmpty()) {
try {
cs.showText(line)
} catch (e: Exception) {
Timber.e(e, "Error drawing text line")
}
}
}
cs.endText()
if (box.isUnderline || box.isStrikeThrough) {
cs.setStrokingColor(tr, tg, tb)
cs.setLineWidth(fontSize / 15f)
var decorY = topY - (fontSize * 0.85f)
for (line in wrappedLines) {
if (line.isNotEmpty()) {
val lineWidth = try { (font.getStringWidth(line) / 1000f) * fontSize } catch(_:Exception){0f}
if (box.isUnderline) {
val underlineY = decorY - (fontSize * 0.15f)
cs.moveTo(boxX, underlineY)
cs.lineTo(boxX + lineWidth, underlineY)
cs.stroke()
}
if (box.isStrikeThrough) {
val strikeY = decorY + (fontSize * 0.3f)
cs.moveTo(boxX, strikeY)
cs.lineTo(boxX + lineWidth, strikeY)
cs.stroke()
}
}
decorY -= lineHeight
}
}
}
}
private fun drawPencilOverlay(
document: PDDocument,
page: PDPage,
annotations: List<PdfAnnotation>,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float
) {
val scale = 2.0f
val bitmapW = (pageWidth * scale).toInt()
val bitmapH = (pageHeight * scale).toInt()
if (bitmapW <= 0 || bitmapH <= 0) return
val bitmap = createBitmap(bitmapW, bitmapH)
val canvas = Canvas(bitmap)
val texture = PdfTextureGenerator.getNoiseTexture()
val paint =
Paint().apply {
isAntiAlias = true
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
shader = BitmapShader(texture, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
}
annotations.forEach { annot ->
if (annot.points.size > 1) {
val strokeWidthPx = annot.strokeWidth * bitmapW
paint.strokeWidth = strokeWidthPx
val adjustedAlpha = (annot.color.alpha * 0.8f).coerceIn(0f, 1f)
paint.colorFilter =
PorterDuffColorFilter(
android.graphics.Color.argb(
(adjustedAlpha * 255).toInt(),
(annot.color.red * 255).toInt(),
(annot.color.green * 255).toInt(),
(annot.color.blue * 255).toInt()
),
PorterDuff.Mode.SRC_IN
)
val path = android.graphics.Path()
val startP = annot.points[0]
path.moveTo(startP.x * bitmapW, startP.y * bitmapH)
for (i in 1 until annot.points.size) {
val p0 = annot.points[i - 1]
val p1 = annot.points[i]
val p0x = p0.x * bitmapW
val p0y = p0.y * bitmapH
val p1x = p1.x * bitmapW
val p1y = p1.y * bitmapH
val midX = (p0x + p1x) / 2f
val midY = (p0y + p1y) / 2f
if (i == 1) path.lineTo(midX, midY) else path.quadTo(p0x, p0y, midX, midY)
}
val last = annot.points.last()
path.lineTo(last.x * bitmapW, last.y * bitmapH)
canvas.drawPath(path, paint)
}
}
val pdImage = LosslessFactory.createFromImage(document, bitmap)
bitmap.recycle()
PDPageContentStream(document, page, PDPageContentStream.AppendMode.APPEND, true, true)
.use { cs -> cs.drawImage(pdImage, 0f, lowerLeftY, pageWidth, pageHeight) }
}
private fun drawFountainPen(
cs: PDPageContentStream,
annotation: PdfAnnotation,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float
) {
if (annotation.points.size < 2) return
val r = annotation.color.red
val g = annotation.color.green
val b = annotation.color.blue
val a = annotation.color.alpha
cs.setNonStrokingColor(r, g, b)
if (a < 1.0f) {
val graphicsState = PDExtendedGraphicsState()
graphicsState.nonStrokingAlphaConstant = a
cs.setGraphicsStateParameters(graphicsState)
}
val baseStrokeWidth = annotation.strokeWidth * pageWidth
val (leftSide, rightSide) =
PdfInkGeometry.calculateFountainPenPoints(
annotation.points,
baseStrokeWidth,
pageWidth,
pageHeight
)
if (leftSide.isNotEmpty()) {
fun fixY(y: Float): Float = lowerLeftY + pageHeight - y
cs.moveTo(leftSide[0].x, fixY(leftSide[0].y))
for (i in 1 until leftSide.size) {
cs.lineTo(leftSide[i].x, fixY(leftSide[i].y))
}
for (i in rightSide.size - 1 downTo 0) {
cs.lineTo(rightSide[i].x, fixY(rightSide[i].y))
}
@Suppress("DEPRECATION") cs.closeSubPath()
cs.fill()
}
if (a < 1.0f) {
val resetState = PDExtendedGraphicsState()
resetState.nonStrokingAlphaConstant = 1.0f
cs.setGraphicsStateParameters(resetState)
}
}
private fun drawStandardAnnotation(
cs: PDPageContentStream,
annotation: PdfAnnotation,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float
) {
if (annotation.points.isEmpty()) return
val r = annotation.color.red
val g = annotation.color.green
val b = annotation.color.blue
val a = annotation.color.alpha
cs.setStrokingColor(r, g, b)
if (a < 1.0f ||
annotation.inkType == InkType.HIGHLIGHTER ||
annotation.inkType == InkType.HIGHLIGHTER_ROUND
) {
val graphicsState = PDExtendedGraphicsState()
graphicsState.strokingAlphaConstant = a
if (annotation.inkType == InkType.HIGHLIGHTER ||
annotation.inkType == InkType.HIGHLIGHTER_ROUND
) {
graphicsState.blendMode = BlendMode.MULTIPLY
}
cs.setGraphicsStateParameters(graphicsState)
}
val lineWidth = annotation.strokeWidth * pageWidth
cs.setLineWidth(lineWidth)
when (annotation.inkType) {
InkType.HIGHLIGHTER -> cs.setLineCapStyle(0)
else -> cs.setLineCapStyle(1)
}
cs.setLineJoinStyle(1)
val points = annotation.points
val startX = points[0].x * pageWidth
val startY = lowerLeftY + pageHeight - (points[0].y * pageHeight)
cs.moveTo(startX, startY)
for (i in 1 until points.size) {
val p0 = points[i - 1]
val p1 = points[i]
val p0x = p0.x * pageWidth
val p0y = lowerLeftY + pageHeight - (p0.y * pageHeight)
val p1x = p1.x * pageWidth
val p1y = lowerLeftY + pageHeight - (p1.y * pageHeight)
val midX = (p0x + p1x) / 2f
val midY = (p0y + p1y) / 2f
if (i == 1) {
cs.lineTo(midX, midY)
} else {
cs.curveTo2(p0x, p0y, midX, midY)
}
}
val lastP = points.last()
val lastX = lastP.x * pageWidth
val lastY = lowerLeftY + pageHeight - (lastP.y * pageHeight)
cs.lineTo(lastX, lastY)
cs.stroke()
val resetState = PDExtendedGraphicsState()
resetState.strokingAlphaConstant = 1.0f
resetState.blendMode = BlendMode.NORMAL
cs.setGraphicsStateParameters(resetState)
}
private data class StyledRun(
val text: String,
val fontSize: Float,
val isBold: Boolean,
val isItalic: Boolean,
val isUnderline: Boolean,
val isStrikethrough: Boolean,
val colorArgb: Int,
val backgroundColorArgb: Int,
val fontPath: String?,
val fontName: String? // Add this field
)
private fun buildStyledRuns(
text: AnnotatedString,
@Suppress("SameParameterValue") startIndex: Int,
endIndex: Int,
scaleFactor: Float
): List<StyledRun> {
if (startIndex >= endIndex || text.text.isEmpty()) return emptyList()
val runs = mutableListOf<StyledRun>()
var currentRunStart = startIndex
val currentStyle = getStyleAt(text, startIndex)
// Updated Tuple to 9 elements
data class StyleProps(
val fontSize: Float,
val isBold: Boolean,
val isItalic: Boolean,
val isUnderline: Boolean,
val isStrikethrough: Boolean,
val colorArgb: Int,
val backgroundColorArgb: Int,
val fontPath: String?,
val fontName: String?
)
fun extractRunProperties(style: SpanStyle): StyleProps {
val fontSize = if (style.fontSize.isSpecified) style.fontSize.value * scaleFactor else 16f * scaleFactor
val isBold = style.fontWeight == FontWeight.Bold
val isItalic = style.fontStyle == FontStyle.Italic
val decoration = style.textDecoration ?: TextDecoration.None
val isUnderline = decoration.contains(TextDecoration.Underline)
val isStrikethrough = decoration.contains(TextDecoration.LineThrough)
val colorArgb = if (style.color != Color.Unspecified) style.color.toArgb() else android.graphics.Color.BLACK
val bgColorArgb = if (style.background != Color.Unspecified) style.background.toArgb() else android.graphics.Color.TRANSPARENT
val fontPath = PdfFontCache.getPath(style.fontFamily)
// Map standard families back to names for the exporter
val fontName = when (style.fontFamily) {
FontFamily.Serif -> "Serif"
FontFamily.Monospace -> "Monospace"
FontFamily.SansSerif -> "Sans"
else -> null
}
return StyleProps(fontSize, isBold, isItalic, isUnderline, isStrikethrough, colorArgb, bgColorArgb, fontPath, fontName)
}
var currentProps = extractRunProperties(currentStyle)
for (i in (startIndex + 1) until endIndex) {
val charStyle = getStyleAt(text, i)
val charProps = extractRunProperties(charStyle)
if (charProps != currentProps) {
val runText = text.text.substring(currentRunStart, i)
runs.add(
StyledRun(
text = runText,
fontSize = currentProps.fontSize,
isBold = currentProps.isBold,
isItalic = currentProps.isItalic,
isUnderline = currentProps.isUnderline,
isStrikethrough = currentProps.isStrikethrough,
colorArgb = currentProps.colorArgb,
backgroundColorArgb = currentProps.backgroundColorArgb,
fontPath = currentProps.fontPath,
fontName = currentProps.fontName // Pass fontName
)
)
currentRunStart = i
currentProps = charProps
}
}
val lastRunText = text.text.substring(currentRunStart, endIndex)
if (lastRunText.isNotEmpty()) {
runs.add(
StyledRun(
text = lastRunText,
fontSize = currentProps.fontSize,
isBold = currentProps.isBold,
isItalic = currentProps.isItalic,
isUnderline = currentProps.isUnderline,
isStrikethrough = currentProps.isStrikethrough,
colorArgb = currentProps.colorArgb,
backgroundColorArgb = currentProps.backgroundColorArgb,
fontPath = currentProps.fontPath,
fontName = currentProps.fontName
)
)
}
return runs
}
private fun drawRichTextLayout(
cs: PDPageContentStream,
layout: PageTextLayout,
pageWidth: Float,
pageHeight: Float,
lowerLeftY: Float,
fontCache: PdfBoxFontCache
) {
val text = layout.visibleText
val layoutPageHeightPx = layout.pageHeightPx
if (text.text.isEmpty()) return
Timber.tag("PdfExportWrap").d("Starting export for Page ${layout.pageIndex}")
val estimatedDensity = 2.3f
val scaleFactor =
if (layoutPageHeightPx > 0) {
estimatedDensity * pageHeight / layoutPageHeightPx
} else {
1.15f
}
val marginX = pageWidth * 0.1f
val marginY = pageHeight * 0.08f
val contentWidth = pageWidth - (marginX * 2)
Timber.tag("PdfExportWrap").d("Layout Constants: pageWidth=$pageWidth, contentWidth=$contentWidth, scaleFactor=$scaleFactor")
val allRuns = buildStyledRuns(text, 0, text.text.length, scaleFactor)
val firstFontSize = allRuns.firstOrNull()?.fontSize ?: (16f * scaleFactor)
var currentY = lowerLeftY + pageHeight - marginY - (firstFontSize * 1.25f)
data class LineRun(val run: StyledRun, val width: Float)
val currentLineRuns = mutableListOf<LineRun>()
var currentLineWidth = 0f
var maxFontSizeInLine = 0f
fun flushLine() {
if (currentLineRuns.isEmpty()) return
Timber.tag("PdfExportWrap").d("Flushing Line: width=$currentLineWidth, y=$currentY, runsCount=${currentLineRuns.size}")
drawLineOfRuns(cs, currentLineRuns.map { it.run }, marginX, currentY, contentWidth, fontCache)
currentY -= (maxFontSizeInLine * 1.2f)
currentLineRuns.clear()
currentLineWidth = 0f
maxFontSizeInLine = 0f
}
for (run in allRuns) {
val parts = run.text.split('\n')
parts.forEachIndexed { partIndex, part ->
if (partIndex > 0) {
flushLine()
if (part.isEmpty()) {
currentY -= (run.fontSize * 1.2f)
return@forEachIndexed
}
}
if (part.isEmpty()) return@forEachIndexed
val tokenizer = StringTokenizer(part, " \t\u000B\u000C\r", true)
while (tokenizer.hasMoreTokens()) {
val token = tokenizer.nextToken()
var remainingToken = token
while (remainingToken.isNotEmpty()) {
val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic)
fun measure(s: String): Float = try {
(font.getStringWidth(s) / 1000f) * run.fontSize
} catch (_: Exception) { 0f }
val tokenWidth = measure(remainingToken)
if (currentLineWidth + tokenWidth <= contentWidth) {
currentLineRuns.add(LineRun(run.copy(text = remainingToken), tokenWidth))
currentLineWidth += tokenWidth
if (run.fontSize > maxFontSizeInLine) maxFontSizeInLine = run.fontSize
remainingToken = ""
}
else if (currentLineRuns.isNotEmpty()) {
flushLine()
}
else {
var low = 1
var high = remainingToken.length
var bestIndex = 1
while (low <= high) {
val mid = (low + high) / 2
if (measure(remainingToken.take(mid)) <= contentWidth) {
bestIndex = mid
low = mid + 1
} else {
high = mid - 1
}
}
val chunk = remainingToken.take(bestIndex)
val chunkWidth = measure(chunk)
currentLineRuns.add(LineRun(run.copy(text = chunk), chunkWidth))
currentLineWidth = chunkWidth
maxFontSizeInLine = run.fontSize
flushLine()
remainingToken = remainingToken.substring(bestIndex)
}
}
}
}
}
flushLine()
}
private fun drawLineOfRuns(
cs: PDPageContentStream,
runs: List<StyledRun>,
startX: Float,
y: Float,
@Suppress("UNUSED_PARAMETER") contentWidth: Float,
fontCache: PdfBoxFontCache
) {
if (runs.isEmpty()) return
var bgX = startX
for (run in runs) {
val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic)
val safeText = run.text.replace("\n", " ")
.replace("\r", "")
.replace("\u000C", "")
.replace("\u200B", "")
val runWidth = (font.getStringWidth(safeText) / 1000f) * run.fontSize
if (run.backgroundColorArgb != android.graphics.Color.TRANSPARENT) {
val r = android.graphics.Color.red(run.backgroundColorArgb) / 255f
val g = android.graphics.Color.green(run.backgroundColorArgb) / 255f
val b = android.graphics.Color.blue(run.backgroundColorArgb) / 255f
cs.setNonStrokingColor(r, g, b)
cs.addRect(bgX, y - (run.fontSize * 0.2f), runWidth, run.fontSize * 1.2f)
cs.fill()
}
bgX += runWidth
}
cs.beginText()
cs.newLineAtOffset(startX, y)
var currentFont: PDFont? = null
var currentFontSize = -1f
var currentColor = -1
android.graphics.Color.BLACK
var currentX = startX
for (run in runs) {
val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic)
val isCustom = !run.fontPath.isNullOrBlank()
if (font != currentFont || run.fontSize != currentFontSize) {
cs.setFont(font, run.fontSize)
currentFont = font
currentFontSize = run.fontSize
}
if (run.colorArgb != currentColor) {
val r = android.graphics.Color.red(run.colorArgb) / 255f
val g = android.graphics.Color.green(run.colorArgb) / 255f
val b = android.graphics.Color.blue(run.colorArgb) / 255f
cs.setNonStrokingColor(r, g, b)
currentColor = run.colorArgb
}
applyStyleSimulations(cs, run.fontSize, run.isBold, run.isItalic, isCustom, currentX, y)
try {
val safeText = run.text.replace("\n", " ").replace("\r", "").replace("\u000C", "").replace("\u200B", "")
cs.showText(safeText)
val runWidth = (font.getStringWidth(safeText) / 1000f) * run.fontSize
currentX += runWidth
} catch (e: Exception) {
Timber.e(e, "Error drawing run: ${run.text}")
}
}
cs.endText()
var decorationX = startX
for (run in runs) {
val font = fontCache.getFont(run.fontPath, run.fontName, run.isBold, run.isItalic)
val safeText = run.text.replace("\n", " ")
.replace("\r", "")
.replace("\u000C", "")
.replace("\u200B", "")
val runWidth = (font.getStringWidth(safeText) / 1000f) * run.fontSize
if (run.isUnderline) {
val r = android.graphics.Color.red(run.colorArgb) / 255f
val g = android.graphics.Color.green(run.colorArgb) / 255f
val b = android.graphics.Color.blue(run.colorArgb) / 255f
cs.setStrokingColor(r, g, b)
cs.setLineWidth(run.fontSize / 15f)
cs.moveTo(decorationX, y - (run.fontSize * 0.15f))
cs.lineTo(decorationX + runWidth, y - (run.fontSize * 0.15f))
cs.stroke()
}
if (run.isStrikethrough) {
val r = android.graphics.Color.red(run.colorArgb) / 255f
val g = android.graphics.Color.green(run.colorArgb) / 255f
val b = android.graphics.Color.blue(run.colorArgb) / 255f
cs.setStrokingColor(r, g, b)
cs.setLineWidth(run.fontSize / 15f)
cs.moveTo(decorationX, y + (run.fontSize * 0.25f))
cs.lineTo(decorationX + runWidth, y + (run.fontSize * 0.25f))
cs.stroke()
}
decorationX += runWidth
}
}
private fun getStyleAt(text: AnnotatedString, index: Int): SpanStyle {
val styles = text.spanStyles.filter { index >= it.start && index < it.end }
var style = SpanStyle()
styles.forEach { style = style.merge(it.item) }
return style
}
}

View file

@ -0,0 +1,277 @@
// PdfHelper.kt
package com.aryan.reader.pdf
import android.graphics.Bitmap
import android.graphics.Rect
import android.os.Handler
import android.os.Looper
import timber.log.Timber
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.aryan.reader.countWords
import io.legere.pdfiumandroid.suspend.PdfTextPageKt
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import com.aryan.reader.OcrEngine
import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrLine
import com.aryan.reader.pdf.ocr.OcrResult
import com.aryan.reader.pdf.ocr.OcrSymbol
enum class OcrLanguage(val displayName: String) {
LATIN("English, Spanish, French, etc."),
DEVANAGARI("Hindi, Marathi, Sanskrit + English"),
CHINESE("Chinese + English"),
JAPANESE("Japanese + English"),
KOREAN("Korean + English")
}
internal data class OcrSymbolInfo(
val symbol: OcrSymbol,
val parentElement: OcrElement,
val parentLine: OcrLine
)
internal data class CustomPdfMenuState(
val selectedText: String,
val anchorRect: Rect,
val charRange: Pair<Int, Int>
)
internal enum class PdfSelectionMethod {
PDFIUM, OCR
}
internal object OcrHelper {
fun init(language: OcrLanguage) {
OcrEngine.init(language)
}
suspend fun extractTextFromBitmap(
bitmap: Bitmap,
onModelDownloading: () -> Unit
): OcrResult? {
return OcrEngine.extractTextFromBitmap(bitmap, onModelDownloading)
}
}
internal suspend fun findWordBoundaries(
textPage: PdfTextPageKt,
initialCharIndex: Int,
pageCharCount: Int
): Pair<Int, Int>? {
if (initialCharIndex !in 0..<pageCharCount) return null
val initialChar = textPage.textPageGetUnicode(initialCharIndex)
if (!initialChar.isLetterOrDigit()) {
Timber.d("Initial char '$initialChar' at index $initialCharIndex is not letter/digit.")
return null
}
var wordStartIndex = initialCharIndex
while (wordStartIndex > 0) {
val char = textPage.textPageGetUnicode(wordStartIndex - 1)
if (!char.isLetterOrDigit()) {
break
}
wordStartIndex--
}
var wordEndIndex = initialCharIndex
while (wordEndIndex < pageCharCount) {
val char = textPage.textPageGetUnicode(wordEndIndex)
if (!char.isLetterOrDigit()) {
break
}
wordEndIndex++
}
return if (wordStartIndex < wordEndIndex) {
Timber.d("Word boundaries: $wordStartIndex to $wordEndIndex (exclusive)")
Pair(wordStartIndex, wordEndIndex)
} else {
Timber.w("Word boundary detection resulted in startIndex >= endIndex ($wordStartIndex >= $wordEndIndex)")
null
}
}
@Composable
internal fun PdfSelectionMenuPopup(
menuState: CustomPdfMenuState,
popupPositionProvider: PopupPositionProvider,
onCopy: (String) -> Unit,
onAiDefine: (String) -> Unit,
onSelectAll: () -> Unit,
isProUser: Boolean,
onShowUpsellDialog: () -> Unit,
) {
Popup(
popupPositionProvider = popupPositionProvider,
onDismissRequest = null,
properties = PopupProperties(
focusable = false,
dismissOnClickOutside = false,
dismissOnBackPress = false
)
) {
Surface(
shape = RoundedCornerShape(8.dp),
shadowElevation = 4.dp,
color = MaterialTheme.colorScheme.surfaceVariant,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f))
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
TextButton(onClick = { onCopy(menuState.selectedText) }) {
Text("Copy")
}
if (menuState.selectedText.length <= 2000) {
TextButton(onClick = {
if (isProUser || countWords(menuState.selectedText) <= 1) {
onAiDefine(menuState.selectedText)
} else {
onShowUpsellDialog()
}
}) {
Text("Dictionary")
}
}
TextButton(onClick = onSelectAll) {
Text("Select All")
}
}
}
}
}
internal fun mergeRectsIntoLines(rects: List<Rect>): List<Rect> {
if (rects.isEmpty()) return emptyList()
val sortedRects = rects.sortedWith(compareBy({ it.top }, { it.left }))
val mergedLines = mutableListOf<Rect>()
var currentLineCombinedRect: Rect? = null
for (rect in sortedRects) {
if (currentLineCombinedRect == null) {
currentLineCombinedRect = Rect(rect)
} else {
val isSameLine = (maxOf(currentLineCombinedRect.top, rect.top) <
minOf(currentLineCombinedRect.bottom, rect.bottom))
if (isSameLine) {
currentLineCombinedRect.union(rect)
} else {
mergedLines.add(currentLineCombinedRect)
currentLineCombinedRect = Rect(rect)
}
}
}
currentLineCombinedRect?.let { mergedLines.add(it) }
return mergedLines
}
internal fun findRectsForTextChunkInOcrVisual(
visionText: OcrResult,
textChunkToHighlight: String
): List<Rect> {
if (textChunkToHighlight.isBlank()) return emptyList()
val allOcrElements = visionText.textBlocks.flatMap { tb -> tb.lines.flatMap { l -> l.elements } }
if (allOcrElements.isEmpty()) return emptyList()
val targetWords = textChunkToHighlight.split(Regex("\\s+")).filter { it.isNotEmpty() }
if (targetWords.isEmpty()) return emptyList()
val matchedRects = mutableListOf<Rect>()
for (i in 0 .. allOcrElements.size - targetWords.size) {
var currentMatch = true
val tempRects = mutableListOf<Rect>()
var ocrTextCombined = ""
for (j in targetWords.indices) {
val ocrElement = allOcrElements[i + j]
ocrTextCombined += ocrElement.text + " "
if (!ocrElement.text.equals(targetWords[j], ignoreCase = true) &&
!ocrElement.text.replace(Regex("[.,;:!?\"')$]"), "").equals(targetWords[j], ignoreCase = true) &&
!targetWords[j].replace(Regex("[.,;:!?\"'(]$"), "").equals(ocrElement.text, ignoreCase = true)
) {
currentMatch = false
break
}
ocrElement.boundingBox?.let {
tempRects.add(
Rect(
it.left,
it.top,
it.right,
it.bottom
)
)
}
}
if (currentMatch) {
Timber.d("OCR Highlight Match: Found sequence for '$textChunkToHighlight' starting with '${allOcrElements[i].text}' -> Combined: $ocrTextCombined")
matchedRects.addAll(tempRects)
return matchedRects
}
}
Timber.d("OCR Highlight No Match: Could not find sequence for '$textChunkToHighlight'")
return emptyList()
}
internal data class ProcessedText(
val cleanText: String,
val indexMap: List<Int>
)
internal sealed class TtsHighlightData {
data class Pdfium(val startIndex: Int, val length: Int) : TtsHighlightData()
data class Ocr(val text: String) : TtsHighlightData()
}
internal fun preprocessTextForTts(rawText: String): ProcessedText {
if (rawText.isBlank()) {
return ProcessedText("", emptyList())
}
val cleanTextBuilder = StringBuilder(rawText.length)
val indexMap = mutableListOf<Int>()
rawText.forEachIndexed { index, char ->
when (char) {
'\n' -> {
val lastChar = cleanTextBuilder.trimEnd().lastOrNull()
if (lastChar != null && lastChar !in ".?!") {
if (cleanTextBuilder.isNotEmpty() && !cleanTextBuilder.last().isWhitespace()) {
cleanTextBuilder.append(' ')
indexMap.add(index)
}
}
}
'\r' -> {
// Ignore carriage returns completely
}
else -> {
cleanTextBuilder.append(char)
indexMap.add(index)
}
}
}
return ProcessedText(cleanTextBuilder.toString().trim(), indexMap)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,377 @@
// PdfTextBox.kt
package com.aryan.reader.pdf
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.ui.zIndex
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.aryan.reader.R
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.aryan.reader.pdf.data.PdfTextBox
import timber.log.Timber
import kotlin.math.roundToInt
private enum class ResizeHandle {
TOP_LEFT, TOP_CENTER, TOP_RIGHT,
RIGHT_CENTER,
BOTTOM_RIGHT, BOTTOM_CENTER, BOTTOM_LEFT,
LEFT_CENTER,
NONE
}
enum class HandlePosition {
TOP, BOTTOM, AUTO
}
@Composable
fun ResizableTextBox(
box: PdfTextBox,
isSelected: Boolean,
isEditMode: Boolean,
isDarkMode: Boolean,
pageWidthPx: Float,
pageHeightPx: Float,
onBoundsChanged: (Rect) -> Unit,
onTextChanged: (String) -> Unit,
onSelect: () -> Unit,
onDragStart: (Offset) -> Unit,
onDrag: (Offset, Rect) -> Unit,
onDragEnd: () -> Unit,
modifier: Modifier = Modifier,
onDragCancel: () -> Unit = {},
handlePosition: HandlePosition = HandlePosition.AUTO
) {
if (pageWidthPx <= 0 || pageHeightPx <= 0) return
val density = LocalDensity.current
val focusRequester = remember { FocusRequester() }
val handleSize = 10.dp
val handleTouchSize = 40.dp
val handleSizePx = with(density) { handleSize.toPx() }
val halfHandlePx = handleSizePx / 2f
val handleTouchSizePx = with(density) { handleTouchSize.toPx() }
val borderColor = if (isDarkMode) Color.White else Color.Black
val handleColor = if (isDarkMode) Color.White else Color.Black
var isDraggingOrResizing by remember { mutableStateOf(false) }
val fontFamily = remember(box.fontPath, box.fontName) {
Timber.tag("PdfFontDebug").d("Rendering Box ${box.id}: FontPath=${box.fontPath}, FontName=${box.fontName}")
if (box.fontPath != null) {
PdfFontCache.getFontFamily(box.fontPath)
} else {
when (box.fontName) {
"Serif" -> FontFamily.Serif
"Sans" -> FontFamily.SansSerif
"Monospace" -> FontFamily.Monospace
"Cursive" -> FontFamily.Cursive
else -> FontFamily.Default
}
}
}
var currentRectPx by remember {
mutableStateOf(
Rect(
left = box.relativeBounds.left * pageWidthPx,
top = box.relativeBounds.top * pageHeightPx,
right = box.relativeBounds.right * pageWidthPx,
bottom = box.relativeBounds.bottom * pageHeightPx
)
)
}
LaunchedEffect(isSelected) {
if (isSelected && isEditMode) {
focusRequester.requestFocus()
}
}
LaunchedEffect(box.relativeBounds, pageWidthPx, pageHeightPx) {
if (!isDraggingOrResizing) {
val newPx = Rect(
left = box.relativeBounds.left * pageWidthPx,
top = box.relativeBounds.top * pageHeightPx,
right = box.relativeBounds.right * pageWidthPx,
bottom = box.relativeBounds.bottom * pageHeightPx
)
if (kotlin.math.abs(newPx.left - currentRectPx.left) > 1f ||
kotlin.math.abs(newPx.top - currentRectPx.top) > 1f ||
kotlin.math.abs(newPx.width - currentRectPx.width) > 1f ||
kotlin.math.abs(newPx.height - currentRectPx.height) > 1f
) {
currentRectPx = newPx
}
}
}
val requiredBottomSpacePx = with(density) { 60.dp.toPx() }
val isHandleAtTop by remember(currentRectPx, pageHeightPx, handlePosition) {
derivedStateOf {
when (handlePosition) {
HandlePosition.TOP -> true
HandlePosition.BOTTOM -> false
HandlePosition.AUTO -> {
if (pageHeightPx <= 0f) {
false
} else {
val spaceBelow = pageHeightPx - currentRectPx.bottom
spaceBelow < requiredBottomSpacePx
}
}
}
}
}
Box(
modifier = modifier
.zIndex(if (isSelected) 10f else 0f)
.offset {
IntOffset(
(currentRectPx.left - halfHandlePx).roundToInt(),
(currentRectPx.top - halfHandlePx).roundToInt()
)
}
.size(
width = with(density) { (currentRectPx.width + handleSizePx).toDp() },
height = with(density) { (currentRectPx.height + handleSizePx).toDp() }
)
) {
// --- 1. Content Body ---
Box(
modifier = Modifier
.fillMaxSize()
.padding(handleSize / 2)
.pointerInput(Unit) {
detectTapGestures { onSelect() }
}
.then(
if (isSelected) Modifier.border(1.5.dp, borderColor) else Modifier
)
) {
BasicTextField(
value = box.text,
onValueChange = onTextChanged,
modifier = Modifier
.fillMaxSize()
.padding(8.dp)
.verticalScroll(rememberScrollState())
.focusRequester(focusRequester),
textStyle = TextStyle(
color = box.color,
background = box.backgroundColor,
fontFamily = fontFamily,
fontSize = with(LocalDensity.current) {
(box.fontSize * pageHeightPx).coerceAtLeast(10f).toSp()
},
fontWeight = if (box.isBold) FontWeight.Bold else FontWeight.Normal,
fontStyle = if (box.isItalic) FontStyle.Italic else FontStyle.Normal,
textDecoration = run {
val decs = mutableListOf<TextDecoration>()
if (box.isUnderline) decs.add(TextDecoration.Underline)
if (box.isStrikeThrough) decs.add(TextDecoration.LineThrough)
if (decs.isEmpty()) TextDecoration.None else TextDecoration.combine(decs)
}
),
cursorBrush = SolidColor(if (isDarkMode) Color.White else MaterialTheme.colorScheme.primary),
enabled = isEditMode && isSelected,
readOnly = !isEditMode
)
}
if (isSelected) {
val handles = ResizeHandle.entries.filter { it != ResizeHandle.NONE }
fun getHandleCenter(handle: ResizeHandle, w: Float, h: Float): Offset {
return when (handle) {
ResizeHandle.TOP_LEFT -> Offset(halfHandlePx, halfHandlePx)
ResizeHandle.TOP_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx)
ResizeHandle.TOP_RIGHT -> Offset(halfHandlePx + w, halfHandlePx)
ResizeHandle.RIGHT_CENTER -> Offset(halfHandlePx + w, halfHandlePx + h / 2)
ResizeHandle.BOTTOM_RIGHT -> Offset(halfHandlePx + w, halfHandlePx + h)
ResizeHandle.BOTTOM_CENTER -> Offset(halfHandlePx + w / 2, halfHandlePx + h)
ResizeHandle.BOTTOM_LEFT -> Offset(halfHandlePx, halfHandlePx + h)
ResizeHandle.LEFT_CENTER -> Offset(halfHandlePx, halfHandlePx + h / 2)
else -> Offset.Zero
}
}
handles.forEach { handle ->
val center = getHandleCenter(handle, currentRectPx.width, currentRectPx.height)
Box(
modifier = Modifier
.offset {
IntOffset(
(center.x - handleTouchSizePx / 2).roundToInt(),
(center.y - handleTouchSizePx / 2).roundToInt()
)
}
.size(handleTouchSize)
.pointerInput(onBoundsChanged) {
detectDragGestures(
onDragStart = { isDraggingOrResizing = true },
onDragEnd = {
isDraggingOrResizing = false
val normalized = Rect(
left = currentRectPx.left / pageWidthPx,
top = currentRectPx.top / pageHeightPx,
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
onBoundsChanged(normalized)
},
onDragCancel = { isDraggingOrResizing = false }
) { change, dragAmount ->
change.consume()
var l = currentRectPx.left
var t = currentRectPx.top
var r = currentRectPx.right
var b = currentRectPx.bottom
val dx = dragAmount.x
val dy = dragAmount.y
val minSize = 50f
when (handle) {
ResizeHandle.TOP_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize)
t = (t + dy).coerceIn(0f, b - minSize)
}
ResizeHandle.TOP_CENTER -> t = (t + dy).coerceIn(0f, b - minSize)
ResizeHandle.TOP_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
t = (t + dy).coerceIn(0f, b - minSize)
}
ResizeHandle.RIGHT_CENTER -> r = (r + dx).coerceIn(l + minSize, pageWidthPx)
ResizeHandle.BOTTOM_RIGHT -> {
r = (r + dx).coerceIn(l + minSize, pageWidthPx)
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
}
ResizeHandle.BOTTOM_CENTER -> b = (b + dy).coerceIn(t + minSize, pageHeightPx)
ResizeHandle.BOTTOM_LEFT -> {
l = (l + dx).coerceIn(0f, r - minSize)
b = (b + dy).coerceIn(t + minSize, pageHeightPx)
}
ResizeHandle.LEFT_CENTER -> l = (l + dx).coerceIn(0f, r - minSize)
else -> {}
}
currentRectPx = Rect(l, t, r, b)
}
}
) {
Box(
modifier = Modifier
.size(handleSize)
.background(handleColor, CircleShape)
.align(Alignment.Center)
)
}
}
DragPill(
isDarkMode = isDarkMode,
modifier = Modifier
.align(if (isHandleAtTop) Alignment.TopCenter else Alignment.BottomCenter)
.offset(y = if (isHandleAtTop) (-32).dp else 32.dp)
.zIndex(20f)
.pointerInput(pageWidthPx, pageHeightPx, onDragStart, onDragEnd, onDragCancel) {
detectDragGestures(
onDragStart = { offset ->
isDraggingOrResizing = true
onDragStart(offset)
},
onDragEnd = {
isDraggingOrResizing = false
val normalized = Rect(
left = currentRectPx.left / pageWidthPx,
top = currentRectPx.top / pageHeightPx,
right = currentRectPx.right / pageWidthPx,
bottom = currentRectPx.bottom / pageHeightPx
)
onBoundsChanged(normalized)
onDragEnd()
},
onDragCancel = {
isDraggingOrResizing = false
onDragCancel()
}
) { change, dragAmount ->
change.consume()
val w = currentRectPx.width
val h = currentRectPx.height
val rawLeft = currentRectPx.left + dragAmount.x
val rawTop = currentRectPx.top + dragAmount.y
val newLeft = rawLeft.coerceIn(0f, pageWidthPx - w)
val newTop = rawTop.coerceIn(0f, pageHeightPx - h)
val newRect = Rect(newLeft, newTop, newLeft + w, newTop + h)
currentRectPx = newRect
onDrag(dragAmount, newRect)
}
}
)
}
}
}
@Composable
private fun DragPill(
modifier: Modifier = Modifier,
isDarkMode: Boolean
) {
Surface(
modifier = modifier
.size(width = 48.dp, height = 24.dp),
shape = CircleShape,
color = if (isDarkMode) Color.White else Color.Black,
contentColor = if (isDarkMode) Color.Black else Color.White,
shadowElevation = 4.dp
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(id = R.drawable.drag_handle),
contentDescription = "Drag to move text box",
modifier = Modifier.size(20.dp)
)
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,553 @@
// PenIcons.kt
package com.aryan.reader.pdf
import android.graphics.BitmapShader
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Shader
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.PathMeasure
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke as ComposeStroke
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.data.PdfAnnotation
import android.graphics.Paint as NativePaint
private val BODY_COLOR = Color(0xFF454545)
private val SILVER_NIB_COLOR = Color(0xFFCFD8DC)
@Composable
fun PenIcon(
color: Color,
modifier: Modifier = Modifier,
type: PenType = PenType.FOUNTAIN_PEN,
isSelected: Boolean = false,
strokeWidth: Float = 0.005f,
forcedInkType: InkType? = null,
inkColor: Color? = null
) {
val animatedColor by animateColorAsState(targetValue = color, label = "color")
val targetInkColor = inkColor ?: color
val animatedInkColor by animateColorAsState(targetValue = targetInkColor, label = "ink_color")
val inkProgress by animateFloatAsState(
targetValue = if (isSelected) 1f else 0f,
animationSpec = tween(durationMillis = 600, easing = LinearEasing),
label = "ink_progress"
)
Canvas(modifier = modifier) {
val w = size.width
val h = size.height
val penWidth = w * 0.65f
val startX = (w - penWidth) / 2f
val tipHeight = h * 0.45f
val collarHeight = h * 0.15f
val bodyHeight = h * 0.35f
val topPadding = h * 0.05f
val tipRect = Rect(offset = Offset(startX, topPadding), size = Size(penWidth, tipHeight))
val collarRect = Rect(offset = Offset(startX, topPadding + tipHeight), size = Size(penWidth, collarHeight))
val bodyRect = Rect(offset = Offset(startX, topPadding + tipHeight + collarHeight), size = Size(penWidth, bodyHeight))
drawMatteCylinder(BODY_COLOR, bodyRect)
when (type) {
PenType.FOUNTAIN_PEN -> {
drawMatteCylinder(animatedColor, collarRect)
drawFountainNib(SILVER_NIB_COLOR, animatedColor, tipRect)
}
PenType.PENCIL -> {
drawMatteCylinder(animatedColor, collarRect)
drawPencilHead(animatedColor, tipRect)
}
PenType.MARKER -> {
drawMatteCylinder(animatedColor, collarRect)
drawMarkerHead(animatedColor, tipRect)
}
PenType.BRUSH -> {
drawMatteCylinder(animatedColor, collarRect)
drawBrushHead(
animatedColor,
Rect(offset = tipRect.topLeft, size = Size(tipRect.width, tipHeight + collarHeight))
)
}
PenType.HIGHLIGHTER -> {
drawHighlighterChiselParts(animatedColor, collarRect, tipRect)
}
PenType.HIGHLIGHTER_ROUND -> {
drawHighlighterRoundParts(animatedColor, collarRect, tipRect)
}
}
if (inkProgress > 0.01f) {
val tipX = size.width / 2f
val tipY = when (type) {
PenType.HIGHLIGHTER -> topPadding
PenType.HIGHLIGHTER_ROUND -> topPadding + tipHeight * 0.15f
else -> topPadding
}
drawInkSquiggle(
type = type,
forcedInkType = forcedInkType,
color = animatedInkColor, // Use the specific ink color
progress = inkProgress,
startPoint = Offset(tipX, tipY),
baseStrokeWidth = strokeWidth
)
}
}
}
// Helpers
private fun DrawScope.drawMatteCylinder(color: Color, rect: Rect) {
val gradient = Brush.horizontalGradient(
0.0f to color.darker(0.6f),
0.3f to color.lighter(0.1f),
0.5f to color,
0.85f to color.darker(0.5f),
1.0f to color.darker(0.7f),
startX = rect.left,
endX = rect.right
)
drawRect(brush = gradient, topLeft = rect.topLeft, size = rect.size)
}
private fun DrawScope.drawFountainNib(metalColor: Color, inkColor: Color, rect: Rect) {
val cx = rect.left + rect.width / 2
val path = Path().apply {
moveTo(rect.left + rect.width * 0.15f, rect.bottom)
lineTo(rect.right - rect.width * 0.15f, rect.bottom)
cubicTo(
rect.right - rect.width * 0.1f, rect.bottom - rect.height * 0.6f,
rect.right, rect.top + rect.height * 0.2f,
cx, rect.top
)
cubicTo(
rect.left, rect.top + rect.height * 0.2f,
rect.left + rect.width * 0.1f, rect.bottom - rect.height * 0.6f,
rect.left + rect.width * 0.15f, rect.bottom
)
close()
}
drawPath(
path = path,
brush = Brush.horizontalGradient(
0.0f to metalColor.darker(0.6f),
0.4f to Color.White,
0.6f to metalColor,
1.0f to metalColor.darker(0.6f),
startX = rect.left,
endX = rect.right
)
)
drawCircle(
color = Color.Black.copy(alpha=0.7f),
radius = rect.width * 0.06f,
center = Offset(cx, rect.bottom - rect.height * 0.5f)
)
drawLine(
color = Color.Black.copy(alpha=0.6f),
start = Offset(cx, rect.top),
end = Offset(cx, rect.bottom - rect.height * 0.5f),
strokeWidth = 2f
)
drawCircle(
color = inkColor.copy(alpha = 0.5f),
radius = rect.width * 0.04f,
center = Offset(cx, rect.bottom - rect.height * 0.5f)
)
}
private fun DrawScope.drawMarkerHead(inkColor: Color, rect: Rect) {
val cx = rect.left + rect.width / 2
val coneHeight = rect.height * 0.8f
val conePath = Path().apply {
moveTo(rect.left, rect.bottom)
lineTo(rect.right, rect.bottom)
lineTo(cx + rect.width * 0.15f, rect.top + (rect.height - coneHeight))
lineTo(cx - rect.width * 0.15f, rect.top + (rect.height - coneHeight))
close()
}
val plasticColor = Color(0xFF616161)
drawPath(
path = conePath,
brush = Brush.horizontalGradient(
0.0f to plasticColor.darker(0.5f),
0.5f to plasticColor,
1.0f to plasticColor.darker(0.5f),
startX = rect.left,
endX = rect.right
)
)
val tipPath = Path().apply {
moveTo(cx - rect.width * 0.15f, rect.top + (rect.height - coneHeight))
lineTo(cx + rect.width * 0.15f, rect.top + (rect.height - coneHeight))
quadraticTo(cx, rect.top, cx, rect.top) // Round tip
lineTo(cx - rect.width * 0.15f, rect.top + (rect.height - coneHeight))
}
drawPath(path = tipPath, color = inkColor)
}
private fun DrawScope.drawPencilHead(inkColor: Color, rect: Rect) {
val cx = rect.left + rect.width / 2
val woodColor = Color(0xFFFFCC80)
val woodPath = Path().apply {
moveTo(rect.left, rect.bottom)
val scallops = 3
val step = rect.width / scallops
for (i in 0 until scallops) {
quadraticTo(
rect.left + (i * step) + (step / 2), rect.bottom - (rect.width * 0.1f),
rect.left + ((i + 1) * step), rect.bottom
)
}
lineTo(cx + rect.width * 0.12f, rect.top + rect.height * 0.25f)
lineTo(cx - rect.width * 0.12f, rect.top + rect.height * 0.25f)
close()
}
drawPath(
path = woodPath,
brush = Brush.horizontalGradient(
0.0f to woodColor.darker(0.3f),
0.5f to woodColor.lighter(0.1f),
1.0f to woodColor.darker(0.3f),
startX = rect.left,
endX = rect.right
)
)
val leadPath = Path().apply {
moveTo(cx - rect.width * 0.12f, rect.top + rect.height * 0.25f)
lineTo(cx + rect.width * 0.12f, rect.top + rect.height * 0.25f)
lineTo(cx, rect.top)
close()
}
drawPath(path = leadPath, color = inkColor)
}
private fun DrawScope.drawBrushHead(inkColor: Color, rect: Rect) {
val cx = rect.left + rect.width / 2
val brushPath = Path().apply {
moveTo(rect.left + rect.width * 0.15f, rect.bottom)
lineTo(rect.right - rect.width * 0.15f, rect.bottom)
quadraticTo(rect.right, rect.bottom - rect.height * 0.4f, cx, rect.top)
quadraticTo(rect.left, rect.bottom - rect.height * 0.4f, rect.left + rect.width * 0.15f, rect.bottom)
close()
}
val gradient = Brush.radialGradient(
colors = listOf(inkColor.lighter(0.4f), inkColor.darker(0.6f)),
center = Offset(cx, rect.top + rect.height * 0.3f),
radius = rect.height
)
drawPath(path = brushPath, brush = gradient)
}
private fun DrawScope.drawHighlighterChiselParts(color: Color, collarRect: Rect, tipRect: Rect) {
drawMatteCylinder(color, collarRect)
val neckHeight = tipRect.height * 0.65f
val inkTipHeight = tipRect.height - neckHeight
val neckBottomY = tipRect.bottom
val neckTopY = tipRect.bottom - neckHeight
val cx = tipRect.center.x
val neckTopHalfWidth = tipRect.width * 0.25f
val neckPath = Path().apply {
moveTo(tipRect.left, neckBottomY)
lineTo(tipRect.right, neckBottomY)
lineTo(cx + neckTopHalfWidth, neckTopY)
lineTo(cx - neckTopHalfWidth, neckTopY)
close()
}
val neckGradient = Brush.horizontalGradient(
0.0f to BODY_COLOR.darker(0.6f),
0.3f to BODY_COLOR.lighter(0.1f),
0.5f to BODY_COLOR,
0.85f to BODY_COLOR.darker(0.5f),
1.0f to BODY_COLOR.darker(0.7f),
startX = tipRect.left,
endX = tipRect.right
)
drawPath(path = neckPath, brush = neckGradient)
val tipBottomY = neckTopY
val tipTopY = tipRect.top
val slantDrop = inkTipHeight * 0.4f
val tipPath = Path().apply {
moveTo(cx - neckTopHalfWidth, tipBottomY)
lineTo(cx + neckTopHalfWidth, tipBottomY)
lineTo(cx + neckTopHalfWidth, tipTopY + slantDrop)
lineTo(cx - neckTopHalfWidth, tipTopY)
close()
}
drawPath(
path = tipPath,
brush = Brush.horizontalGradient(
0.0f to color.darker(0.8f),
0.5f to color,
1.0f to color.darker(0.8f),
startX = cx - neckTopHalfWidth,
endX = cx + neckTopHalfWidth
)
)
val facePath = Path().apply {
moveTo(cx - neckTopHalfWidth, tipTopY)
lineTo(cx + neckTopHalfWidth, tipTopY + slantDrop)
quadraticTo(cx, tipTopY + slantDrop * 0.5f, cx - neckTopHalfWidth, tipTopY)
close()
}
drawPath(path = facePath, color = color.lighter(0.2f))
}
private fun DrawScope.drawHighlighterRoundParts(color: Color, collarRect: Rect, tipRect: Rect) {
drawMatteCylinder(color, collarRect)
val neckHeight = tipRect.height * 0.65f
val neckBottomY = tipRect.bottom
val neckTopY = tipRect.bottom - neckHeight
val cx = tipRect.center.x
val neckTopHalfWidth = tipRect.width * 0.25f
val neckPath = Path().apply {
moveTo(tipRect.left, neckBottomY)
lineTo(tipRect.right, neckBottomY)
lineTo(cx + neckTopHalfWidth, neckTopY)
lineTo(cx - neckTopHalfWidth, neckTopY)
close()
}
val neckGradient = Brush.horizontalGradient(
0.0f to BODY_COLOR.darker(0.6f),
0.3f to BODY_COLOR.lighter(0.1f),
0.5f to BODY_COLOR,
0.85f to BODY_COLOR.darker(0.5f),
1.0f to BODY_COLOR.darker(0.7f),
startX = tipRect.left,
endX = tipRect.right
)
drawPath(path = neckPath, brush = neckGradient)
neckTopHalfWidth * 2
val tipHeight = tipRect.height - neckHeight
val domeRect = Rect(
left = cx - neckTopHalfWidth,
top = neckTopY - tipHeight,
right = cx + neckTopHalfWidth,
bottom = neckTopY
)
val domePath = Path().apply {
moveTo(domeRect.left, domeRect.bottom)
lineTo(domeRect.right, domeRect.bottom)
arcTo(
rect = domeRect,
startAngleDegrees = 0f,
sweepAngleDegrees = -180f,
forceMoveTo = false
)
close()
}
drawPath(
path = domePath,
brush = Brush.radialGradient(
colors = listOf(color.lighter(0.3f), color, color.darker(0.6f)),
center = Offset(domeRect.center.x - domeRect.width * 0.2f, domeRect.top + domeRect.height * 0.4f),
radius = domeRect.width
)
)
}
private fun DrawScope.drawInkSquiggle(
type: PenType,
forcedInkType: InkType?,
color: Color,
progress: Float,
startPoint: Offset,
baseStrokeWidth: Float
) {
val x = startPoint.x
val y = startPoint.y - 2f
val path = Path().apply {
moveTo(x, y)
if (type == PenType.HIGHLIGHTER || type == PenType.HIGHLIGHTER_ROUND) {
val waveWidth = 70f
val amplitude = 20f
cubicTo(
x + waveWidth * 0.35f, y - amplitude,
x + waveWidth * 0.65f, y + amplitude,
x + waveWidth, y
)
} else {
cubicTo(
x + 35f, y - 40f,
x - 35f, y - 90f,
x - 15f, y - 45f
)
cubicTo(
x - 5f, y - 10f,
x + 50f, y - 25f,
x + 70f, y - 55f
)
}
}
val inkType = forcedInkType ?: when(type) {
PenType.FOUNTAIN_PEN -> InkType.FOUNTAIN_PEN
PenType.PENCIL -> InkType.PENCIL
PenType.MARKER -> InkType.PEN
PenType.HIGHLIGHTER, PenType.HIGHLIGHTER_ROUND -> InkType.HIGHLIGHTER
else -> InkType.PEN
}
val pathMeasure = PathMeasure()
pathMeasure.setPath(path, false)
val length = pathMeasure.length
val targetLength = length * progress
val pointCount = (targetLength / 2f).toInt().coerceAtLeast(2)
val points = ArrayList<PdfPoint>(pointCount)
var currentTime = 0L
for (i in 0 until pointCount) {
val distance = (i.toFloat() / pointCount) * targetLength
val timeDelta = 15L
currentTime += timeDelta
pathMeasure.getPosition(distance).let { offset ->
points.add(PdfPoint(offset.x, offset.y, timestamp = currentTime))
}
}
if (points.isEmpty()) return
val simulationScale = 1000f
val strokeMultiplier = if (type == PenType.HIGHLIGHTER || type == PenType.HIGHLIGHTER_ROUND) 1.0f else 1f
val scaledStrokeWidth = baseStrokeWidth * simulationScale * strokeMultiplier
val annotation = PdfAnnotation(
type = AnnotationType.INK,
inkType = inkType,
pageIndex = 0,
points = points,
color = color,
strokeWidth = scaledStrokeWidth
)
val renderData = PdfAnnotationRenderHelper.createRenderData(
annot = annotation,
widthPx = 1,
heightPx = 1
)
if (renderData != null) {
when (renderData) {
is AnnotationRenderData.Standard -> {
val effectiveBlendMode = if (type == PenType.HIGHLIGHTER || type == PenType.HIGHLIGHTER_ROUND) {
BlendMode.SrcOver
} else if (renderData.blendMode == BlendMode.Darken) {
BlendMode.SrcOver
} else {
renderData.blendMode
}
// Handle caps for specific highlighters
val strokeCap = when (type) {
PenType.HIGHLIGHTER -> StrokeCap.Square
PenType.HIGHLIGHTER_ROUND -> StrokeCap.Round
else -> renderData.cap
}
drawPath(
path = renderData.path,
color = renderData.color,
style = ComposeStroke(
width = renderData.strokeWidth,
cap = strokeCap,
join = StrokeJoin.Round
),
blendMode = effectiveBlendMode
)
}
is AnnotationRenderData.Fountain -> {
drawPath(
path = renderData.path,
color = renderData.color,
style = androidx.compose.ui.graphics.drawscope.Fill
)
}
is AnnotationRenderData.Pencil -> {
val texture = PdfTextureGenerator.getNoiseTexture()
drawIntoCanvas { canvas ->
val paint = NativePaint().apply {
isAntiAlias = true
style = NativePaint.Style.STROKE
strokeCap = NativePaint.Cap.ROUND
strokeJoin = NativePaint.Join.ROUND
strokeWidth = renderData.strokeWidth
shader = BitmapShader(
texture, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT
)
colorFilter = PorterDuffColorFilter(
renderData.color.toArgb(), PorterDuff.Mode.SRC_IN
)
alpha = (renderData.color.alpha * renderData.velocityAlpha * 255).toInt()
}
canvas.nativeCanvas.drawPath(renderData.path, paint)
}
}
}
}
}
enum class PenType {
FOUNTAIN_PEN, PENCIL, MARKER, BRUSH, HIGHLIGHTER, HIGHLIGHTER_ROUND
}
fun Color.darker(factor: Float = 0.7f): Color {
return Color(
red = this.red * factor,
green = this.green * factor,
blue = this.blue * factor,
alpha = this.alpha
)
}
fun Color.lighter(factor: Float = 0.3f): Color {
val r = this.red + (1 - this.red) * factor
val g = this.green + (1 - this.green) * factor
val b = this.blue + (1 - this.blue) * factor
return Color(r, g, b, this.alpha)
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,250 @@
// SvgToAnnotationConverter.kt
@file:Suppress("SameParameterValue")
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.Path
import android.util.Xml
import androidx.compose.ui.graphics.Color
import androidx.core.graphics.PathParser
import com.aryan.reader.pdf.data.PdfAnnotation
import org.xmlpull.v1.XmlPullParser
import timber.log.Timber
import java.util.Stack
import kotlin.math.hypot
import androidx.core.graphics.toColorInt
object SvgToAnnotationConverter {
private data class SvgStyle(
val strokeColor: Color? = null,
val strokeWidth: Float? = null,
val fill: Color? = null,
val opacity: Float = 1.0f
)
fun importSvgFromAssets(
context: Context,
fileName: String,
pageIndex: Int,
): List<PdfAnnotation> {
val annotations = mutableListOf<PdfAnnotation>()
var currentTime = System.currentTimeMillis()
try {
context.assets.open(fileName).use { inputStream ->
val parser = Xml.newPullParser()
parser.setInput(inputStream, null)
var eventType = parser.eventType
var viewBoxWidth = 800f
@Suppress("VariableNeverRead") var viewBoxHeight = 300f
val styleStack = Stack<SvgStyle>()
styleStack.push(SvgStyle(strokeColor = Color.Black, strokeWidth = 2f))
val targetWidthPercent = 0.8f
val startX = (1f - targetWidthPercent) / 2f
val startY = 0.3f
var scale = 1f
while (eventType != XmlPullParser.END_DOCUMENT) {
val tagName = parser.name
when (eventType) {
XmlPullParser.START_TAG -> {
if (tagName.equals("svg", ignoreCase = true)) {
val viewBox = parser.getAttributeValue(null, "viewBox")
if (viewBox != null) {
val parts = viewBox.split(" ").mapNotNull { it.toFloatOrNull() }
if (parts.size == 4) {
viewBoxWidth = parts[2]
viewBoxHeight = parts[3]
}
}
scale = targetWidthPercent / viewBoxWidth
}
val rawStroke = parser.getAttributeValue(null, "stroke")
val rawStrokeWidth = parser.getAttributeValue(null, "stroke-width")?.toFloatOrNull()
val rawFill = parser.getAttributeValue(null, "fill")
val rawOpacity = parser.getAttributeValue(null, "opacity")?.toFloatOrNull() ?: 1.0f
val strokeColor = parseSvgColor(rawStroke)
val fillColor = parseSvgColor(rawFill)
val parentStyle = styleStack.peek()
val currentStyle = SvgStyle(
strokeColor = strokeColor ?: parentStyle.strokeColor,
strokeWidth = rawStrokeWidth ?: parentStyle.strokeWidth,
fill = fillColor ?: parentStyle.fill,
opacity = rawOpacity * parentStyle.opacity
)
if (tagName.equals("g", ignoreCase = true)) {
styleStack.push(currentStyle)
}
if (tagName.equals("circle", ignoreCase = true)) {
val cx = parser.getAttributeValue(null, "cx")?.toFloatOrNull() ?: 0f
val cy = parser.getAttributeValue(null, "cy")?.toFloatOrNull() ?: 0f
val r = parser.getAttributeValue(null, "r")?.toFloatOrNull() ?: 0f
if (r > 0) {
val finalColor = (currentStyle.fill ?: currentStyle.strokeColor ?: Color.Black)
.copy(alpha = currentStyle.opacity)
val pdfDiameter = (2 * r) * scale
val pdfCx = startX + (cx * scale)
val pdfCy = startY + (cy * scale)
val points = listOf(
PdfPoint(pdfCx, pdfCy, currentTime),
PdfPoint(pdfCx + 0.00001f, pdfCy, currentTime + 1)
)
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = pageIndex,
points = points,
color = finalColor,
strokeWidth = pdfDiameter
)
)
currentTime += 5
}
}
// --- PATH HANDLING ---
if (tagName.equals("path", ignoreCase = true)) {
val d = parser.getAttributeValue(null, "d")
if (!d.isNullOrBlank()) {
val subPathDataStrings = d.split(Regex("(?=[Mm])")).filter { it.isNotBlank() }
subPathDataStrings.forEach { subPathData ->
try {
val finalColor = (currentStyle.strokeColor ?: currentStyle.fill ?: Color.Black)
.copy(alpha = currentStyle.opacity)
val svgStrokeWidth = currentStyle.strokeWidth ?: 1f
val pdfStrokeWidth = svgStrokeWidth * scale
val path = PathParser.createPathFromPathData(subPathData)
val points = flattenPathToPdfPoints(
path = path,
scale = scale,
offsetX = startX,
offsetY = startY,
baseTime = currentTime
)
if (points.isNotEmpty()) {
annotations.add(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = pageIndex,
points = points,
color = finalColor,
strokeWidth = pdfStrokeWidth
)
)
currentTime += points.size
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse sub-path data")
}
}
}
}
}
XmlPullParser.END_TAG -> {
if (tagName.equals("g", ignoreCase = true)) {
if (styleStack.size > 1) {
styleStack.pop()
}
}
}
}
eventType = parser.next()
}
}
} catch (e: Exception) {
Timber.e(e, "Error importing SVG")
}
return annotations
}
private fun parseSvgColor(hexOrName: String?): Color? {
if (hexOrName.isNullOrBlank() || hexOrName.equals("none", ignoreCase = true)) return null
return try {
Color(hexOrName.toColorInt())
} catch (_: Exception) {
null
}
}
private fun flattenPathToPdfPoints(
path: Path,
scale: Float,
offsetX: Float,
offsetY: Float,
baseTime: Long
): List<PdfPoint> {
val coords = path.approximate(0.5f)
val rawPoints = mutableListOf<PdfPoint>()
var timeOffset = 0L
var i = 0
while (i < coords.size) {
val x = coords[i + 1]
val y = coords[i + 2]
val pdfX = offsetX + (x * scale)
val pdfY = offsetY + (y * scale)
rawPoints.add(PdfPoint(pdfX, pdfY, baseTime + timeOffset))
timeOffset++
i += 3
}
return densifyPoints(rawPoints, threshold = 0.001f)
}
private fun densifyPoints(points: List<PdfPoint>, threshold: Float): List<PdfPoint> {
if (points.size < 2) return points
val result = mutableListOf<PdfPoint>()
result.add(points[0])
for (i in 0 until points.size - 1) {
val p1 = points[i]
val p2 = points[i + 1]
val dist = hypot(p2.x - p1.x, p2.y - p1.y)
if (dist > threshold) {
val steps = (dist / threshold).toInt()
for (j in 1..steps) {
val fraction = j.toFloat() / (steps + 1)
val newX = p1.x + (p2.x - p1.x) * fraction
val newY = p1.y + (p2.y - p1.y) * fraction
val newTime = p1.timestamp + ((p2.timestamp - p1.timestamp) * fraction).toLong()
result.add(PdfPoint(newX, newY, newTime))
}
}
result.add(p2)
}
return result
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,989 @@
// ToolSettingsPopup.kt
package com.aryan.reader.pdf
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.drag
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.core.graphics.toColorInt
import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToolSettingsPopup(
selectedTool: InkType,
activeToolThickness: Float,
fountainPenColor: Color,
markerColor: Color,
pencilColor: Color,
highlighterColor: Color,
highlighterRoundColor: Color,
activePalette: List<Color>,
onToolTypeChanged: (InkType) -> Unit,
onColorChanged: (Color) -> Unit,
onThicknessChanged: (Float) -> Unit,
onPaletteChange: (List<Color>) -> Unit,
modifier: Modifier = Modifier
) {
val isHighlighter = selectedTool == InkType.HIGHLIGHTER || selectedTool == InkType.HIGHLIGHTER_ROUND
val activeColor = when (selectedTool) {
InkType.FOUNTAIN_PEN -> fountainPenColor
InkType.PEN -> markerColor
InkType.PENCIL -> pencilColor
InkType.HIGHLIGHTER -> highlighterColor
InkType.HIGHLIGHTER_ROUND -> highlighterRoundColor
else -> markerColor
}
val currentAlpha = activeColor.alpha
val safeOnColorChanged: (Color) -> Unit = { newColor ->
if (isHighlighter) {
onColorChanged(newColor.copy(alpha = currentAlpha))
} else {
onColorChanged(newColor)
}
}
// Thickness settings
val thicknessRange = if (isHighlighter) 0.01f..0.06f else 0.001f..0.015f
@Suppress("UnusedExpression") if (isHighlighter) 0.005f else 0.001f
var showColorPicker by remember { mutableStateOf(false) }
var colorPickerSlotIndex by remember { mutableIntStateOf(-1) }
val currentOnColorChanged by rememberUpdatedState(safeOnColorChanged)
val selectedPaletteIndex = remember(activePalette, activeColor, isHighlighter) {
activePalette.indexOfFirst { paletteColor ->
if (isHighlighter) {
paletteColor.copy(alpha = 1f) == activeColor.copy(alpha = 1f)
} else {
paletteColor == activeColor
}
}
}
val circleSize = 28.dp
Surface(
modifier = modifier
.width(360.dp)
.padding(12.dp),
shape = RoundedCornerShape(28.dp),
color = Color(0xFF1E1E1E),
shadowElevation = 12.dp,
tonalElevation = 0.dp
) {
Column(
modifier = Modifier.padding(20.dp), // Reduced padding
horizontalAlignment = Alignment.CenterHorizontally
) {
// Pen Type Selector
Box(
modifier = Modifier
.fillMaxWidth()
.height(125.dp),
contentAlignment = Alignment.BottomCenter
) {
Row(
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.Bottom
) {
if (isHighlighter) {
PenItem(
type = PenType.HIGHLIGHTER,
forcedInkType = InkType.HIGHLIGHTER,
color = highlighterColor.copy(alpha = 1f),
inkColor = highlighterColor,
isSelected = selectedTool == InkType.HIGHLIGHTER,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.HIGHLIGHTER) }
)
PenItem(
type = PenType.HIGHLIGHTER_ROUND,
forcedInkType = InkType.HIGHLIGHTER_ROUND,
color = highlighterRoundColor.copy(alpha = 1f),
inkColor = highlighterRoundColor,
isSelected = selectedTool == InkType.HIGHLIGHTER_ROUND,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.HIGHLIGHTER_ROUND) }
)
} else {
PenItem(
type = PenType.FOUNTAIN_PEN,
forcedInkType = InkType.FOUNTAIN_PEN,
color = fountainPenColor,
isSelected = selectedTool == InkType.FOUNTAIN_PEN,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.FOUNTAIN_PEN) }
)
PenItem(
type = PenType.MARKER,
forcedInkType = InkType.PEN,
color = markerColor,
isSelected = selectedTool == InkType.PEN,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.PEN) }
)
PenItem(
type = PenType.PENCIL,
forcedInkType = InkType.PENCIL,
color = pencilColor,
isSelected = selectedTool == InkType.PENCIL,
strokeWidth = activeToolThickness,
onClick = { onToolTypeChanged(InkType.PENCIL) }
)
}
}
}
Spacer(Modifier.height(16.dp))
// THICKNESS SLIDER
StyledPropertySlider(
value = activeToolThickness,
onValueChange = onThicknessChanged,
valueRange = thicknessRange, isOpacity = false,
trackColor = Color(0xFF424242),
thumbColor = Color(0xFF757575),
activeColor = activeColor
)
// Darkness (Opacity) Slider for Highlighters
if (isHighlighter) {
Spacer(Modifier.height(16.dp))
StyledPropertySlider(
value = currentAlpha,
onValueChange = { newAlpha ->
onColorChanged(activeColor.copy(alpha = newAlpha))
},
valueRange = 0.1f..1.0f, isOpacity = true,
trackColor = activeColor.copy(alpha = 1f),
thumbColor = activeColor.copy(alpha = 1f),
activeColor = activeColor
)
}
Spacer(Modifier.height(16.dp)) // Reduced spacing
// --- Color Palette ---
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.SpaceBetween
) {
activePalette.take(6).forEachIndexed { index, color ->
val isSelected = index == selectedPaletteIndex
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(circleSize)
.testTag("Palette_Item_$index")
.pointerInput(color) {
detectTapGestures(
onTap = {
currentOnColorChanged(color)
},
onLongPress = {
colorPickerSlotIndex = index
showColorPicker = true
}
)
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawCircle(color = color.copy(alpha = 1f))
if (isSelected) {
drawCircle(
color = Color.White,
radius = size.minDimension / 2,
style = Stroke(width = 2.dp.toPx())
)
}
}
}
}
}
Spacer(Modifier.width(16.dp))
// Divider
Box(
modifier = Modifier
.width(1.dp)
.height(circleSize)
.background(Color.White.copy(alpha = 0.15f))
)
Spacer(Modifier.width(16.dp))
// Spectrum / Color Wheel Button
val rainbowColors = listOf(
Color.Red, Color.Magenta, Color.Blue, Color.Cyan, Color.Green, Color.Yellow, Color.Red
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(circleSize)
.clip(CircleShape)
.background(Brush.sweepGradient(rainbowColors))
.clickable {
if (selectedPaletteIndex != -1) {
colorPickerSlotIndex = selectedPaletteIndex
showColorPicker = true
}
}
) {}
}
}
}
if (showColorPicker && colorPickerSlotIndex != -1) {
val initialColor = activePalette.getOrElse(colorPickerSlotIndex) { Color.Black }
ColorPickerDialog(
initialColor = initialColor,
onDismiss = { showColorPicker = false },
onColorSelected = { newColor ->
val mutableList = activePalette.toMutableList()
if (colorPickerSlotIndex in mutableList.indices) {
mutableList[colorPickerSlotIndex] = newColor
onPaletteChange(mutableList)
if (isHighlighter) {
onColorChanged(newColor.copy(alpha = currentAlpha))
} else {
onColorChanged(newColor)
}
}
showColorPicker = false
}
)
}
}
@Composable
private fun ColorPickerDialog(
initialColor: Color,
onDismiss: () -> Unit,
onColorSelected: (Color) -> Unit
) {
val lockedInitialColor = remember { initialColor }
val initialHsv = remember(initialColor) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(initialColor.toArgb(), hsv)
hsv
}
var hue by remember { mutableFloatStateOf(initialHsv[0]) }
var saturation by remember { mutableFloatStateOf(initialHsv[1]) }
var value by remember { mutableFloatStateOf(initialHsv[2]) }
val alpha = 1.0f
val currentColor by remember {
derivedStateOf {
val hsv = floatArrayOf(hue, saturation, value)
val argb = android.graphics.Color.HSVToColor((alpha * 255).toInt(), hsv)
Color(argb)
}
}
fun updateFromColor(color: Color) {
val hsv = FloatArray(3)
android.graphics.Color.colorToHSV(color.toArgb(), hsv)
hue = hsv[0]
saturation = hsv[1]
value = hsv[2]
}
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Surface(
shape = RoundedCornerShape(24.dp),
color = Color(0xFF2C2C2C),
modifier = Modifier
.fillMaxWidth(0.85f)
.padding(8.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.background(Color(0xFF3E3E3E), RoundedCornerShape(16.dp))
.padding(horizontal = 24.dp, vertical = 8.dp)
) {
Text(
text = "Spectrum",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White
)
}
Spacer(Modifier.height(20.dp))
SpectrumBox(
hue = hue,
saturation = saturation,
currentColor = currentColor,
onHueSatChanged = { h, s ->
hue = h
saturation = s
},
modifier = Modifier
.fillMaxWidth()
.height(220.dp)
)
Spacer(Modifier.height(20.dp))
BrightnessSlider(
hue = hue,
saturation = saturation,
value = value,
onValueChanged = { value = it },
modifier = Modifier
.fillMaxWidth()
.height(24.dp)
.clip(RoundedCornerShape(12.dp))
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
ColorComparePill(
oldColor = lockedInitialColor,
newColor = currentColor,
modifier = Modifier
.width(64.dp)
.height(36.dp)
)
Column(
modifier = Modifier.weight(1.6f),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
"Hex",
color = Color.Gray,
fontSize = 12.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
HexInput(
color = currentColor,
onHexChanged = { updateFromColor(it) }
)
}
Row(
modifier = Modifier.weight(2.4f),
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
RgbInputColumn(
label = "Red",
value = currentColor.red,
onValueChange = { r -> updateFromColor(currentColor.copy(red = r)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "Green",
value = currentColor.green,
onValueChange = { g -> updateFromColor(currentColor.copy(green = g)) },
modifier = Modifier.weight(1f)
)
RgbInputColumn(
label = "Blue",
value = currentColor.blue,
onValueChange = { b -> updateFromColor(currentColor.copy(blue = b)) },
modifier = Modifier.weight(1f)
)
}
}
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = onDismiss) {
Text("Cancel", color = Color.Gray)
}
Spacer(Modifier.width(8.dp))
Button(
onClick = { onColorSelected(currentColor) },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color.Black
)
) {
Text("Done")
}
}
}
}
}
}
@Composable
private fun SpectrumBox(
hue: Float,
saturation: Float,
currentColor: Color,
onHueSatChanged: (Float, Float) -> Unit,
modifier: Modifier = Modifier
) {
val rainbowColors = listOf(
Color.Red, Color.Yellow, Color.Green, Color.Cyan, Color.Blue, Color.Magenta, Color.Red
)
val touchPadding = 12.dp
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
val paddingPx = touchPadding.toPx()
val activeWidth = size.width.toFloat() - (paddingPx * 2)
val activeHeight = size.height.toFloat() - (paddingPx * 2)
fun update(offset: Offset) {
val relativeX = offset.x - paddingPx
val relativeY = offset.y - paddingPx
val h = (relativeX / activeWidth).coerceIn(0f, 1f) * 360f
val s = (relativeY / activeHeight).coerceIn(0f, 1f)
onHueSatChanged(h, s)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(
modifier = Modifier
.fillMaxSize()
.padding(touchPadding)
.clip(RoundedCornerShape(12.dp))
) {
drawRect(
brush = Brush.horizontalGradient(rainbowColors)
)
drawRect(
brush = Brush.verticalGradient(
colors = listOf(Color.White, Color.White.copy(alpha = 0f))
)
)
}
Canvas(modifier = Modifier.fillMaxSize()) {
val paddingPx = touchPadding.toPx()
val activeWidth = size.width - (paddingPx * 2)
val activeHeight = size.height - (paddingPx * 2)
val x = paddingPx + (hue / 360f) * activeWidth
val y = paddingPx + saturation * activeHeight
val pointerRadius = 10.dp.toPx()
val strokeWidth = 2.dp.toPx()
drawCircle(
color = Color.Black.copy(alpha = 0.25f),
radius = pointerRadius + 1.dp.toPx(),
center = Offset(x, y + 1.dp.toPx())
)
drawCircle(
color = currentColor.copy(alpha = 1f),
radius = pointerRadius,
center = Offset(x, y)
)
drawCircle(
color = Color.White,
radius = pointerRadius,
center = Offset(x, y),
style = Stroke(width = strokeWidth)
)
}
}
}
@Composable
private fun BrightnessSlider(
hue: Float,
saturation: Float,
value: Float,
onValueChanged: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val baseColor = remember(hue, saturation) {
Color.hsv(hue, saturation, 1f)
}
Box(
modifier = modifier.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
fun update(offset: Offset) {
val v = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
onValueChanged(v)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
drawRect(
brush = Brush.horizontalGradient(
colors = listOf(Color.Black, baseColor)
)
)
val x = value * size.width
drawCircle(
color = Color.White,
radius = 8.dp.toPx(),
center = Offset(x, size.height / 2)
)
}
}
}
@Composable
private fun RgbInputColumn(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier
) {
val intValue = (value * 255).roundToInt()
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
Text(
text = label,
color = Color.Gray,
fontSize = 11.sp,
maxLines = 1
)
Spacer(Modifier.height(4.dp))
RgbInput(value = intValue, onValueChange = onValueChange)
}
}
@Composable
private fun RgbInput(
value: Int,
onValueChange: (Float) -> Unit
) {
var text by remember(value) { mutableStateOf(value.toString()) }
LaunchedEffect(value) {
text = value.toString()
}
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 3 && newText.all { it.isDigit() }) {
val intVal = newText.toIntOrNull()
if (intVal != null) {
onValueChange(intVal.coerceIn(0, 255) / 255f)
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Center,
fontSize = 13.sp
),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
singleLine = true,
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(vertical = 9.dp)
)
}
@Composable
private fun HexInput(
color: Color,
onHexChanged: (Color) -> Unit
) {
val hexValue = remember(color) {
String.format("%06X", (0xFFFFFF and color.toArgb()))
}
var text by remember(hexValue) { mutableStateOf(hexValue) }
LaunchedEffect(color) {
val currentParsed = try {
Color(("#$text").toColorInt())
} catch (_: Exception) {
null
}
if (currentParsed?.toArgb() != color.toArgb()) {
text = String.format("%06X", (0xFFFFFF and color.toArgb()))
}
}
Row(
modifier = Modifier
.fillMaxWidth()
.height(36.dp)
.background(Color(0xFF3E3E3E), RoundedCornerShape(8.dp))
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Text(
text = "#",
color = Color.Gray,
fontSize = 13.sp,
fontWeight = FontWeight.Bold
)
BasicTextField(
value = text,
onValueChange = { newText ->
if (newText.length <= 6) {
val uppercased = newText.uppercase()
if (uppercased.all { it.isDigit() || it in 'A'..'F' }) {
text = uppercased
if (uppercased.length == 6) {
try {
val parsedColorInt = "#$uppercased".toColorInt()
val newColor = Color(parsedColorInt)
onHexChanged(newColor)
} catch (_: Exception) {
}
}
}
}
},
textStyle = TextStyle(
color = Color.White,
textAlign = TextAlign.Start,
fontSize = 13.sp
),
singleLine = true,
cursorBrush = SolidColor(Color.White),
modifier = Modifier
.padding(start = 2.dp)
.width(50.dp)
)
}
}
@Composable
private fun ColorComparePill(
oldColor: Color,
newColor: Color,
modifier: Modifier = Modifier
) {
Canvas(modifier = modifier.clip(RoundedCornerShape(8.dp))) {
drawRect(
color = oldColor.copy(alpha = 1f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
drawRect(
color = newColor.copy(alpha = 1f),
topLeft = Offset(size.width / 2, 0f),
size = androidx.compose.ui.geometry.Size(size.width / 2, size.height)
)
}
}
@Composable
private fun PenItem(
type: PenType,
color: Color,
isSelected: Boolean,
strokeWidth: Float,
onClick: () -> Unit,
forcedInkType: InkType? = null,
inkColor: Color? = null
) {
val scale by animateFloatAsState(
targetValue = if (isSelected) 1.15f else 0.9f, label = "scale"
)
Box(
modifier = Modifier
.width(44.dp)
.height(100.dp)
.scale(scale)
.testTag("SettingsItem_${type.name}")
.semantics { this.selected = isSelected }
.clickable(onClick = onClick),
contentAlignment = Alignment.BottomCenter
) {
PenIcon(
color = color,
inkColor = inkColor,
type = type,
isSelected = isSelected,
strokeWidth = strokeWidth,
forcedInkType = forcedInkType,
modifier = Modifier.fillMaxSize()
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun StyledPropertySlider(
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>, isOpacity: Boolean,
trackColor: Color,
thumbColor: Color,
activeColor: Color
) {
val displayValue = remember(value, valueRange) {
val fraction = (value - valueRange.start) / (valueRange.endInclusive - valueRange.start)
(fraction * 100).roundToInt().coerceIn(1, 100)
}
val onePercentDelta = (valueRange.endInclusive - valueRange.start) / 100f
val canDecrease = value > valueRange.start + 0.0001f
val canIncrease = value < valueRange.endInclusive - 0.0001f
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
) {
// Minus Button
Box(
modifier = Modifier
.size(32.dp)
.testTag("Property_Minus")
.clickable(enabled = canDecrease) {
val newValue = (value - onePercentDelta).coerceAtLeast(valueRange.start)
onValueChange(newValue)
},
contentAlignment = Alignment.Center
) {
Text(
text = "",
color = if (canDecrease) Color.White else Color.White.copy(alpha = 0.3f),
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.width(4.dp))
// Custom Slider
Box(modifier = Modifier.weight(1f)) {
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
colors = SliderDefaults.colors(
thumbColor = Color.Transparent,
activeTrackColor = Color.Transparent,
inactiveTrackColor = Color.Transparent
),
modifier = Modifier.height(32.dp),
thumb = {
Surface(
shape = CircleShape,
color = thumbColor,
modifier = Modifier
.size(26.dp)
.padding(2.dp),
shadowElevation = 4.dp,
border = if (isOpacity) null else androidx.compose.foundation.BorderStroke(1.dp, Color.Gray)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = displayValue.toString(),
color = Color.White,
fontSize = 10.sp,
fontWeight = FontWeight.Bold
)
}
}
},
track = { _ ->
Canvas(
modifier = Modifier
.fillMaxWidth()
.height(16.dp)
) {
val trackHeight = size.height
val cornerRadius = CornerRadius(trackHeight / 2)
if (isOpacity) {
drawRoundRect(
color = Color.Gray,
size = size,
cornerRadius = cornerRadius
)
val clipPath = androidx.compose.ui.graphics.Path().apply {
addRoundRect(
androidx.compose.ui.geometry.RoundRect(
rect = androidx.compose.ui.geometry.Rect(Offset.Zero, size),
cornerRadius = cornerRadius
)
)
}
clipPath(clipPath) {
val boxSize = 12f
val columns = (size.width / boxSize).toInt() + 1
val rows = (size.height / boxSize).toInt() + 1
for (col in 0 until columns) {
for (row in 0 until rows) {
val color = if ((col + row) % 2 == 0) Color(0xFF555555) else Color(0xFF333333)
drawRect(
color = color,
topLeft = Offset(col * boxSize, row * boxSize),
size = androidx.compose.ui.geometry.Size(boxSize, boxSize)
)
}
}
drawRect(color = activeColor)
}
} else {
drawRoundRect(
color = trackColor.copy(alpha = 0.5f),
size = size,
cornerRadius = cornerRadius
)
val dotRadius = 1.5.dp.toPx()
val padding = trackHeight / 2
val availableWidth = size.width - (padding * 2)
val dotCount = 8
val spacing = availableWidth / (dotCount - 1)
for (i in 0 until dotCount) {
drawCircle(
color = Color.White.copy(alpha = 0.2f),
radius = dotRadius,
center = Offset(padding + (i * spacing), size.height / 2)
)
}
}
}
}
)
}
Spacer(modifier = Modifier.width(4.dp))
// Plus Button
Box(
modifier = Modifier
.size(32.dp)
.testTag("Property_Plus")
.clickable(enabled = canIncrease) {
val newValue = (value + onePercentDelta).coerceAtMost(valueRange.endInclusive)
onValueChange(newValue)
},
contentAlignment = Alignment.Center
) {
Text(
text = "+",
color = if (canIncrease) Color.White else Color.White.copy(alpha = 0.3f),
fontSize = 22.sp,
fontWeight = FontWeight.Normal
)
}
}
}

View file

@ -0,0 +1,168 @@
// AnnotationSettingsRepository.kt
package com.aryan.reader.pdf.data
import android.content.Context
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.InkType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import timber.log.Timber
import androidx.core.graphics.toColorInt
import androidx.core.content.edit
@Serializable
data class ToolConfig(
val colorArgb: Int,
val thickness: Float
)
@Serializable
data class TextStyleConfig(
val colorArgb: Int = android.graphics.Color.BLACK,
val backgroundColorArgb: Int = android.graphics.Color.TRANSPARENT,
val fontSize: Float = 16f,
val isBold: Boolean = false,
val isItalic: Boolean = false,
val isUnderline: Boolean = false,
val isStrikeThrough: Boolean = false,
val fontPath: String? = null,
val fontName: String? = null
)
@Serializable
data class AnnotationToolSettings(
val selectedToolName: String = "PEN",
val lastActivePenType: String = "PEN",
val toolConfigs: Map<String, ToolConfig> = emptyMap(),
val penPaletteArgb: List<Int> = listOf(
android.graphics.Color.BLACK,
android.graphics.Color.RED,
android.graphics.Color.BLUE,
android.graphics.Color.rgb(76, 175, 80),
android.graphics.Color.WHITE
),
val highlighterPaletteArgb: List<Int> = listOf(
"#8CFF9800".toColorInt(), // Orange (Default)
"#8CFFEB3B".toColorInt(), // Yellow
"#8C81C784".toColorInt(), // Green
"#8C64B5F6".toColorInt(), // Blue
"#8CE1BEE7".toColorInt(), // Purple
),
val textStyle: TextStyleConfig = TextStyleConfig()
) {
fun getActiveTool(): InkType = try {
InkType.valueOf(selectedToolName)
} catch (_: Exception) {
InkType.PEN
}
fun getLastPenTool(): InkType = try {
InkType.valueOf(lastActivePenType)
} catch (_: Exception) {
InkType.PEN
}
fun getToolColor(type: InkType): Color {
val config = toolConfigs[type.name] ?: AnnotationSettingsRepository.getDefaultConfig(type)
return Color(config.colorArgb)
}
fun getToolThickness(type: InkType): Float {
val config = toolConfigs[type.name] ?: AnnotationSettingsRepository.getDefaultConfig(type)
return config.thickness
}
fun getPenPalette(): List<Color> = penPaletteArgb.map { Color(it) }
fun getHighlighterPalette(): List<Color> = highlighterPaletteArgb.map { Color(it) }
}
class AnnotationSettingsRepository(context: Context) {
private val prefs = context.getSharedPreferences("annotation_settings_global", Context.MODE_PRIVATE)
private val scope = CoroutineScope(Dispatchers.IO)
private val json = Json { ignoreUnknownKeys = true }
private val keySettings = "tool_settings_v4_defaults"
private val _settings = MutableStateFlow(loadSettings())
val settings = _settings.asStateFlow()
companion object {
fun getDefaultConfig(type: InkType): ToolConfig {
return when (type) {
InkType.PEN -> ToolConfig(android.graphics.Color.RED, 0.008f)
InkType.FOUNTAIN_PEN -> ToolConfig(android.graphics.Color.BLUE, 0.008f)
InkType.PENCIL -> ToolConfig(android.graphics.Color.DKGRAY, 0.008f)
InkType.HIGHLIGHTER -> ToolConfig("#8CFF9800".toColorInt(), 0.035f)
InkType.HIGHLIGHTER_ROUND -> ToolConfig("#8CFFEB3B".toColorInt(), 0.035f)
InkType.ERASER -> ToolConfig(android.graphics.Color.WHITE, 0.03f)
InkType.TEXT -> ToolConfig(android.graphics.Color.BLACK, 0.02f)
}
}
}
private fun loadSettings(): AnnotationToolSettings {
val jsonString = prefs.getString(keySettings, null)
return if (jsonString != null) {
try {
json.decodeFromString(jsonString)
} catch (e: Exception) {
Timber.e(e, "Failed to decode annotation settings")
AnnotationToolSettings()
}
} else {
AnnotationToolSettings()
}
}
private fun saveSettings(newSettings: AnnotationToolSettings) {
_settings.update { newSettings }
scope.launch {
val jsonString = json.encodeToString(newSettings)
prefs.edit { putString(keySettings, jsonString) }
}
}
fun updateSelectedTool(tool: InkType) {
var currentSettings = _settings.value.copy(selectedToolName = tool.name)
if (tool == InkType.PEN || tool == InkType.FOUNTAIN_PEN || tool == InkType.PENCIL) {
currentSettings = currentSettings.copy(lastActivePenType = tool.name)
}
saveSettings(currentSettings)
}
fun updateToolColor(tool: InkType, color: Color) {
val currentMap = _settings.value.toolConfigs.toMutableMap()
val currentConfig = currentMap[tool.name] ?: getDefaultConfig(tool)
currentMap[tool.name] = currentConfig.copy(colorArgb = color.toArgb())
saveSettings(_settings.value.copy(toolConfigs = currentMap))
}
fun updateToolThickness(tool: InkType, thickness: Float) {
val currentMap = _settings.value.toolConfigs.toMutableMap()
val currentConfig = currentMap[tool.name] ?: getDefaultConfig(tool)
currentMap[tool.name] = currentConfig.copy(thickness = thickness)
saveSettings(_settings.value.copy(toolConfigs = currentMap))
}
fun updatePenPalette(colors: List<Color>) {
saveSettings(_settings.value.copy(penPaletteArgb = colors.map { it.toArgb() }))
}
fun updateHighlighterPalette(colors: List<Color>) {
saveSettings(_settings.value.copy(highlighterPaletteArgb = colors.map { it.toArgb() }))
}
fun updateTextStyle(style: TextStyleConfig) {
saveSettings(_settings.value.copy(textStyle = style))
}
}

View file

@ -0,0 +1,120 @@
// PageLayoutRepository.kt
package com.aryan.reader.pdf.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import timber.log.Timber
sealed class VirtualPage {
data class PdfPage(val pdfIndex: Int) : VirtualPage()
data class BlankPage(val id: String, val width: Int, val height: Int, val wasManuallyAdded: Boolean = false) : VirtualPage()
}
class PageLayoutRepository(private val context: Context) {
private fun getFile(bookId: String): File {
val safeId = bookId.replace("/", "_")
val dir = File(context.filesDir, "page_layouts")
if (!dir.exists()) dir.mkdirs()
return File(dir, "layout_$safeId.json")
}
suspend fun saveLayout(bookId: String, pages: List<VirtualPage>) = withContext(Dispatchers.IO) {
val jsonArray = JSONArray()
pages.forEach { page ->
val obj = JSONObject()
when (page) {
is VirtualPage.PdfPage -> {
obj.put("type", "pdf")
obj.put("index", page.pdfIndex)
}
is VirtualPage.BlankPage -> {
obj.put("type", "blank")
obj.put("id", page.id)
obj.put("w", page.width)
obj.put("h", page.height)
obj.put("manual", page.wasManuallyAdded)
}
}
jsonArray.put(obj)
}
getFile(bookId).writeText(jsonArray.toString())
}
suspend fun loadLayout(bookId: String, totalPdfPages: Int): List<VirtualPage> = withContext(Dispatchers.IO) {
val file = getFile(bookId)
if (!file.exists()) {
return@withContext (0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
}
try {
val json = file.readText()
val array = JSONArray(json)
val list = mutableListOf<VirtualPage>()
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
val type = obj.optString("type", "pdf")
if (type == "pdf") {
list.add(VirtualPage.PdfPage(obj.getInt("index")))
} else {
val w = obj.optInt("w", 595)
val h = obj.optInt("h", 842)
val isManual = obj.optBoolean("manual", false)
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h, isManual))
}
}
list
} catch (_: Exception) {
(0 until totalPdfPages).map { VirtualPage.PdfPage(it) }
}
}
suspend fun getLayoutOrNull(bookId: String): List<VirtualPage>? = withContext(Dispatchers.IO) {
val file = getFile(bookId)
Timber.tag("PdfExportDebug").d("PageLayoutRepo: Looking for layout at ${file.absolutePath}")
Timber.tag("PdfExportDebug").d("PageLayoutRepo: File exists: ${file.exists()}")
if (!file.exists()) {
Timber.tag("PdfExportDebug").w("PageLayoutRepo: No layout file for book $bookId")
return@withContext null
}
try {
val json = file.readText()
Timber.tag("PdfExportDebug").v("PageLayoutRepo: Layout JSON: ${json.take(300)}")
val array = JSONArray(json)
val list = mutableListOf<VirtualPage>()
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
val type = obj.optString("type", "pdf")
if (type == "pdf") {
list.add(VirtualPage.PdfPage(obj.getInt("index")))
} else {
val w = obj.optInt("w", 595)
val h = obj.optInt("h", 842)
list.add(VirtualPage.BlankPage(obj.getString("id"), w, h))
}
}
Timber.tag("PdfExportDebug").i("PageLayoutRepo: Parsed ${list.size} virtual pages (${
list.count { it is VirtualPage.PdfPage }
} PDF, ${list.count { it is VirtualPage.BlankPage }} blank)")
list
} catch (e: Exception) {
Timber.tag("PdfExportDebug").e(e, "PageLayoutRepo: Failed to parse layout")
null
}
}
fun getLayoutFile(bookId: String): File {
val safeId = bookId.replace("/", "_")
val dir = File(context.filesDir, "page_layouts")
if (!dir.exists()) dir.mkdirs()
val file = File(dir, "layout_$safeId.json")
Timber.tag("PdfExportDebug").v("PageLayoutRepo: Layout file path: ${file.absolutePath}")
return file
}
}

View file

@ -0,0 +1,192 @@
// PdfAnnotationData.kt
package com.aryan.reader.pdf.data
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.AnnotationType
import com.aryan.reader.pdf.InkType
import com.aryan.reader.pdf.PdfPoint
import org.json.JSONArray
import org.json.JSONObject
import java.util.Locale
data class PdfTextBox(
val id: String,
val pageIndex: Int,
val relativeBounds: Rect,
val text: String,
val color: Color,
val backgroundColor: Color,
val fontSize: Float,
val isBold: Boolean = false,
val isItalic: Boolean = false,
val isUnderline: Boolean = false,
val isStrikeThrough: Boolean = false,
val fontPath: String? = null,
val fontName: String? = null
)
data class PdfAnnotation(
val type: AnnotationType,
val inkType: InkType = InkType.PEN,
val pageIndex: Int,
val points: List<PdfPoint>,
val color: Color,
val strokeWidth: Float
)
object AnnotationSerializer {
fun toJson(annotations: Map<Int, List<PdfAnnotation>>): String {
val rootArray = JSONArray()
annotations.forEach { (_, list) ->
list.forEach { annotation ->
val obj = JSONObject()
obj.put("pageIndex", annotation.pageIndex)
obj.put("annotationType", annotation.type.name)
obj.put("inkType", annotation.inkType.name)
obj.put("color", annotation.color.toArgb())
obj.put("strokeWidth", annotation.strokeWidth.toDouble())
val pointsArray = JSONArray()
annotation.points.forEach { p ->
val pObj = JSONObject()
pObj.put("x", String.format(Locale.US, "%.5f", p.x).toDouble())
pObj.put("y", String.format(Locale.US, "%.5f", p.y).toDouble())
pObj.put("t", p.timestamp)
pointsArray.put(pObj)
}
obj.put("points", pointsArray)
rootArray.put(obj)
}
}
return rootArray.toString()
}
fun fromJson(json: String): Map<Int, List<PdfAnnotation>> {
val resultMap = mutableMapOf<Int, MutableList<PdfAnnotation>>()
if (json.isBlank()) return emptyMap()
try {
val rootArray = JSONArray(json)
for (i in 0 until rootArray.length()) {
val obj = rootArray.getJSONObject(i)
val pageIndex = obj.getInt("pageIndex")
val annTypeStr = obj.optString("annotationType", AnnotationType.INK.name)
val annType = try { AnnotationType.valueOf(annTypeStr) } catch(_: Exception) { AnnotationType.INK }
val inkTypeStr = obj.optString("inkType", "PEN")
val finalInkTypeStr = if (obj.has("inkType")) inkTypeStr else obj.optString("type", "PEN")
val inkType = try { InkType.valueOf(finalInkTypeStr) } catch(_: Exception) { InkType.PEN }
val colorInt = obj.getInt("color")
val strokeWidth = obj.getDouble("strokeWidth").toFloat()
val pointsArray = obj.getJSONArray("points")
val points = ArrayList<PdfPoint>()
for (j in 0 until pointsArray.length()) {
val pObj = pointsArray.getJSONObject(j)
points.add(
PdfPoint(
x = pObj.getDouble("x").toFloat(),
y = pObj.getDouble("y").toFloat(),
timestamp = pObj.optLong("t", 0L)
)
)
}
val annotation = PdfAnnotation(
type = annType,
inkType = inkType,
pageIndex = pageIndex,
points = points,
color = Color(colorInt),
strokeWidth = strokeWidth
)
if (!resultMap.containsKey(pageIndex)) {
resultMap[pageIndex] = mutableListOf()
}
resultMap[pageIndex]?.add(annotation)
}
} catch (e: Exception) {
e.printStackTrace()
}
return resultMap
}
}
object TextBoxSerializer {
fun toJson(textBoxes: List<PdfTextBox>): String {
val rootArray = JSONArray()
textBoxes.forEach { box ->
val obj = JSONObject()
obj.put("id", box.id)
obj.put("pageIndex", box.pageIndex)
obj.put("text", box.text)
obj.put("color", box.color.toArgb())
obj.put("backgroundColor", box.backgroundColor.toArgb())
obj.put("fontSize", box.fontSize.toDouble())
obj.put("isBold", box.isBold)
obj.put("isItalic", box.isItalic)
obj.put("isUnderline", box.isUnderline)
obj.put("isStrikeThrough", box.isStrikeThrough)
if (box.fontPath != null) {
obj.put("fontPath", box.fontPath)
}
if (box.fontName != null) {
obj.put("fontName", box.fontName)
}
val rectObj = JSONObject()
rectObj.put("left", box.relativeBounds.left.toDouble())
rectObj.put("top", box.relativeBounds.top.toDouble())
rectObj.put("right", box.relativeBounds.right.toDouble())
rectObj.put("bottom", box.relativeBounds.bottom.toDouble())
obj.put("bounds", rectObj)
rootArray.put(obj)
}
return rootArray.toString()
}
fun fromJson(json: String): List<PdfTextBox> {
val result = mutableListOf<PdfTextBox>()
if (json.isBlank()) return result
try {
val rootArray = JSONArray(json)
for (i in 0 until rootArray.length()) {
val obj = rootArray.getJSONObject(i)
val rectObj = obj.getJSONObject("bounds")
val rect = Rect(
rectObj.getDouble("left").toFloat(),
rectObj.getDouble("top").toFloat(),
rectObj.getDouble("right").toFloat(),
rectObj.getDouble("bottom").toFloat()
)
result.add(
PdfTextBox(
id = obj.getString("id"),
pageIndex = obj.getInt("pageIndex"),
relativeBounds = rect,
text = obj.optString("text", ""),
color = Color(obj.getInt("color")),
backgroundColor = Color(obj.getInt("backgroundColor")),
fontSize = obj.getDouble("fontSize").toFloat(),
isBold = obj.optBoolean("isBold", false),
isItalic = obj.optBoolean("isItalic", false),
isUnderline = obj.optBoolean("isUnderline", false),
isStrikeThrough = obj.optBoolean("isStrikeThrough", false),
fontPath = obj.optString("fontPath", null).takeIf { !it.isNullOrBlank() },
fontName = obj.optString("fontName", null).takeIf { !it.isNullOrBlank() }
)
)
}
} catch (e: Exception) {
e.printStackTrace()
}
return result
}
}

View file

@ -0,0 +1,66 @@
// PdfAnnotationRepository.kt
package com.aryan.reader.pdf.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
class PdfAnnotationRepository(private val context: Context) {
private fun getFile(bookId: String): File {
val safeBookId = bookId.replace("/", "_")
val dir = File(context.filesDir, "annotations")
if (!dir.exists()) dir.mkdirs()
return File(dir, "annotation_$safeBookId.json")
}
suspend fun saveAnnotations(bookId: String, annotations: Map<Int, List<PdfAnnotation>>) {
withContext(Dispatchers.IO) {
try {
Timber.tag("AnnotationSync").d("Start saving local JSON for $bookId. Count: ${annotations.size}")
if (annotations.isEmpty()) {
return@withContext
}
val json = AnnotationSerializer.toJson(annotations)
val file = getFile(bookId)
file.writeText(json)
Timber.tag("AnnotationSync").d("Finished saving local JSON for $bookId. Path: ${file.absolutePath}, Size: ${file.length()}")
} catch (e: Exception) {
Timber.tag("AnnotationSync").e(e, "Failed to save local annotations")
}
}
}
suspend fun loadAnnotations(bookId: String): Map<Int, List<PdfAnnotation>> {
return withContext(Dispatchers.IO) {
try {
val file = getFile(bookId)
if (file.exists()) {
val json = file.readText()
Timber.tag("AnnotationSync").d("Loaded local JSON for $bookId. Size: ${file.length()}")
AnnotationSerializer.fromJson(json)
} else {
Timber.tag("AnnotationSync").d("No local annotation file found for $bookId")
emptyMap()
}
} catch (e: Exception) {
Timber.tag("AnnotationSync").e(e, "Failed to load local annotations")
emptyMap()
}
}
}
fun getAnnotationFileForSync(bookId: String): File? {
val file = getFile(bookId)
val valid = file.exists() && file.length() > 0
Timber.tag("AnnotationSync").d("Checking file for sync: $bookId. Exists: ${file.exists()}, Size: ${file.length()} bytes. Valid: $valid")
return if (valid) file else null
}
}

View file

@ -0,0 +1,55 @@
package com.aryan.reader.pdf.data
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
class PdfTextBoxRepository(private val context: Context) {
private fun getFile(bookId: String): File {
val safeBookId = bookId.replace("/", "_")
val dir = File(context.filesDir, "textboxes")
if (!dir.exists()) dir.mkdirs()
return File(dir, "textboxes_$safeBookId.json")
}
suspend fun saveTextBoxes(bookId: String, textBoxes: List<PdfTextBox>) {
withContext(Dispatchers.IO) {
if (textBoxes.isEmpty()) {
val file = getFile(bookId)
if (file.exists()) file.delete()
return@withContext
}
val json = TextBoxSerializer.toJson(textBoxes)
getFile(bookId).writeText(json)
}
}
suspend fun loadTextBoxes(bookId: String): List<PdfTextBox> {
return withContext(Dispatchers.IO) {
val file = getFile(bookId)
if (file.exists()) {
TextBoxSerializer.fromJson(file.readText())
} else {
emptyList()
}
}
}
fun getFileForSync(bookId: String): File {
return getFile(bookId)
}
fun clearAll() {
val dir = File(context.filesDir, "textboxes")
if (dir.exists()) {
dir.listFiles()?.forEach { it.delete() }
}
}
fun deleteForBook(bookId: String) {
val file = getFile(bookId)
if(file.exists()) file.delete()
}
}

View file

@ -0,0 +1,143 @@
// PdfTextDatabase.kt
package com.aryan.reader.pdf.data
import android.content.Context
import androidx.paging.PagingSource
import androidx.room.Dao
import androidx.room.Database
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.Fts4
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import kotlinx.coroutines.flow.Flow
@Fts4(tokenizer = "unicode61")
@Entity(tableName = "pdf_search_index")
data class PdfSearchIndex(
@PrimaryKey @ColumnInfo(name = "rowid") val rowid: Int? = null,
val bookId: String,
val pageIndex: Int,
val content: String
)
data class PdfSearchMatch(
val pageIndex: Int,
@ColumnInfo(name = "snippet") val snippet: String,
@ColumnInfo(name = "content") val content: String
)
@Entity(tableName = "pdf_metadata")
data class PdfMetadata(
@PrimaryKey val bookId: String,
val totalPages: Int,
val ratiosJson: String,
val ocrLanguage: String = "LATIN"
)
@Dao
interface PdfTextDao {
@Query("""
SELECT pageIndex, snippet(pdf_search_index, '<b>', '</b>', '...', -1, 15) as snippet, content
FROM pdf_search_index
WHERE bookId = :bookId AND pdf_search_index MATCH :query
ORDER BY pageIndex ASC
""")
fun searchBookFlow(bookId: String, query: String): Flow<List<PdfSearchMatch>>
@Query("""
SELECT pageIndex, snippet(pdf_search_index, '<b>', '</b>', '...', -1, 15) as snippet, content
FROM pdf_search_index
WHERE bookId = :bookId AND pdf_search_index MATCH :query
ORDER BY pageIndex ASC
""")
fun searchBookPagingSource(bookId: String, query: String): PagingSource<Int, PdfSearchMatch>
@Query("""
SELECT pageIndex, snippet(pdf_search_index, '<b>', '</b>', '...', -1, 15) as snippet, content
FROM pdf_search_index
WHERE bookId = :bookId AND pdf_search_index MATCH :query
ORDER BY pageIndex ASC
""")
suspend fun getAllMatches(bookId: String, query: String): List<PdfSearchMatch>
@Query("""
SELECT count(*)
FROM pdf_search_index
WHERE bookId = :bookId AND pdf_search_index MATCH :query
""")
suspend fun countMatches(bookId: String, query: String): Int
@Query("""
SELECT pageIndex, content, '' as snippet
FROM pdf_search_index
WHERE bookId = :bookId AND pageIndex >= :minPageIndex AND pdf_search_index MATCH :query
ORDER BY pageIndex ASC
LIMIT 1
""")
suspend fun getNextPageWithMatch(bookId: String, query: String, minPageIndex: Int): PdfSearchMatch?
@Query("""
SELECT pageIndex, content, '' as snippet
FROM pdf_search_index
WHERE bookId = :bookId AND pageIndex <= :maxPageIndex AND pdf_search_index MATCH :query
ORDER BY pageIndex DESC
LIMIT 1
""")
suspend fun getPrevPageWithMatch(bookId: String, query: String, maxPageIndex: Int): PdfSearchMatch?
@Query("SELECT content FROM pdf_search_index WHERE bookId = :bookId AND pageIndex = :pageIndex")
suspend fun getPageText(bookId: String, pageIndex: Int): String?
@Query("SELECT pageIndex FROM pdf_search_index WHERE bookId = :bookId")
suspend fun getIndexedPageIndices(bookId: String): List<Int>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPageText(entity: PdfSearchIndex)
@Query("DELETE FROM pdf_search_index WHERE bookId = :bookId")
suspend fun clearBookText(bookId: String)
@Query("DELETE FROM pdf_search_index")
suspend fun deleteAll()
}
@Dao
interface PdfMetaDao {
@Query("SELECT * FROM pdf_metadata WHERE bookId = :bookId")
suspend fun getMetadata(bookId: String): PdfMetadata?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMetadata(metadata: PdfMetadata)
@Query("UPDATE pdf_metadata SET ocrLanguage = :language WHERE bookId = :bookId")
suspend fun updateLanguage(bookId: String, language: String)
}
@Database(entities = [PdfSearchIndex::class, PdfMetadata::class], version = 5, exportSchema = false)
abstract class PdfTextDatabase : RoomDatabase() {
abstract fun pdfTextDao(): PdfTextDao
abstract fun pdfMetaDao(): PdfMetaDao
companion object {
@Volatile
private var INSTANCE: PdfTextDatabase? = null
fun getDatabase(context: Context): PdfTextDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
PdfTextDatabase::class.java,
"pdf_text_cache_db"
).fallbackToDestructiveMigration(true)
.build()
INSTANCE = instance
instance
}
}
}
}

View file

@ -0,0 +1,570 @@
// PdfTextRepository.kt
package com.aryan.reader.pdf.data
import android.content.Context
import android.graphics.RectF
import timber.log.Timber
import androidx.core.graphics.createBitmap
import com.aryan.reader.pdf.OcrHelper
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import org.json.JSONArray
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import androidx.paging.flatMap
import com.aryan.reader.SearchResult
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.Color
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.map
private const val TAG = "PdfSearchDiag"
sealed interface SmartSearchResult {
data class Exact(val matches: List<SearchResult>) : SmartSearchResult
data class Paged(val pagingData: Flow<PagingData<SearchResult>>, val totalPageCount: Int) : SmartSearchResult
}
class PdfTextRepository(context: Context) {
private val db = PdfTextDatabase.getDatabase(context)
private val dao = db.pdfTextDao()
private val metaDao = db.pdfMetaDao()
suspend fun getPageRatios(bookId: String): List<Float>? {
return withContext(Dispatchers.IO) {
val meta = metaDao.getMetadata(bookId)
if (meta != null && meta.ratiosJson.isNotEmpty()) {
try {
val jsonArray = JSONArray(meta.ratiosJson)
val list = ArrayList<Float>(jsonArray.length())
for (i in 0 until jsonArray.length()) {
list.add(jsonArray.getDouble(i).toFloat())
}
list
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to parse ratios json")
null
}
} else {
null
}
}
}
suspend fun savePageRatios(bookId: String, ratios: List<Float>) {
withContext(Dispatchers.IO) {
try {
val jsonString = JSONArray(ratios).toString()
val existing = metaDao.getMetadata(bookId)
val lang = existing?.ocrLanguage ?: "LATIN"
metaDao.insertMetadata(PdfMetadata(bookId, ratios.size, jsonString, lang))
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to save page ratios")
}
}
}
suspend fun getBookLanguage(bookId: String): String? {
return withContext(Dispatchers.IO) {
metaDao.getMetadata(bookId)?.ocrLanguage
}
}
suspend fun setBookLanguage(bookId: String, language: String) {
withContext(Dispatchers.IO) {
val existing = metaDao.getMetadata(bookId)
if (existing != null) {
metaDao.updateLanguage(bookId, language)
} else {
metaDao.insertMetadata(PdfMetadata(bookId, 0, "", language))
}
}
}
suspend fun getIndexedPages(bookId: String): Set<Int> {
return withContext(Dispatchers.IO) {
dao.getIndexedPageIndices(bookId).toSet()
}
}
/**
* Helper to generate a safe FTS query by stripping punctuation from tokens.
* This fixes issues where "xyz." would fail to match "xyz" in the index.
* The strict punctuation check is handled later by the Regex filter.
*/
private fun generateFtsQuery(query: String): String {
val sb = StringBuilder()
for (char in query) {
// Keep letters, digits, and underscores. Replace everything else (punctuation) with space.
if (char.isLetterOrDigit() || char == '_') {
sb.append(char)
} else {
sb.append(' ')
}
}
// Split by whitespace and create FTS prefix tokens (e.g., "content:token*")
val tokens = sb.toString().split("\\s+".toRegex()).filter { it.isNotBlank() }
// If query was only punctuation (e.g. "?"), return a token that likely matches nothing or handle gracefully.
// Returning empty string causes 'MATCH ""' which usually returns nothing.
return tokens.joinToString(" ") { "content:$it*" }
}
fun searchBookFlow(bookId: String, query: String): Flow<List<PdfSearchMatch>> {
val trimmed = query.trim()
if (trimmed.isBlank()) {
return dao.searchBookFlow(bookId, "")
}
// Use the sanitized FTS query for database retrieval
val ftsQuery = generateFtsQuery(trimmed)
// Use the strict Regex for precise filtering
val phraseRegex = createPhraseRegex(query)
Timber.tag(TAG).i("Search initiated for bookId length: ${bookId.length}")
Timber.tag(TAG).i("User Query: '$query'")
Timber.tag(TAG).i("Generated FTS Query: '$ftsQuery'")
Timber.tag(TAG).i("Generated Regex: '$phraseRegex'")
// Filter the FTS matches to ensure they satisfy the strict phrase regex
return dao.searchBookFlow(bookId, ftsQuery).map { list ->
list.filter { match ->
phraseRegex.containsMatchIn(match.content)
}
}
}
suspend fun indexPage(
bookId: String,
document: PdfDocumentKt,
pageIndex: Int,
onOcrModelDownloading: () -> Unit = {}
): Boolean {
return withContext(Dispatchers.IO) {
var text = ""
var ocrUsed = false
try {
document.openPage(pageIndex).use { page ->
page.openTextPage().use { textPage ->
val count = textPage.textPageCountChars()
if (count > 0) {
val nativeText = textPage.textPageGetText(0, count)
if (!nativeText.isNullOrBlank()) {
text = nativeText
}
}
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Native extraction failed for page $pageIndex")
}
if (text.isBlank()) {
try {
document.openPage(pageIndex).use { page ->
val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint()
if (ptrWidth > 0 && ptrHeight > 0) {
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onOcrModelDownloading)
text = visionText?.text ?: ""
bitmap.recycle()
ocrUsed = true
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "OCR failed for page $pageIndex")
}
}
if (text.isNotEmpty()) {
val rawLength = text.length
val patterns = listOf(
Regex("(?i)file:/?/?/?\\S+"),
Regex("(?i)/data/user/\\d+/\\S+"),
Regex("(?i)/storage/emulated/\\d+/\\S+"),
Regex("(?i)\\S*com\\.aryan\\.reader\\S*")
)
patterns.forEach { pattern ->
text = text.replace(pattern, " ")
}
text = text.replace(Regex("\\s+"), " ").trim()
val cleanedLength = text.length
val snippetClean = text.take(50).replace("\n", " ")
if (cleanedLength < rawLength) {
Timber.tag(TAG).d("Page $pageIndex cleaned. Size reduced: $rawLength -> $cleanedLength. New Start: '$snippetClean'")
}
if (text.contains("file://") || text.length > 10 && text.startsWith("/")) {
Timber.tag(TAG).e("Page $pageIndex: Cleaning might have failed. Text still looks like path: $snippetClean")
} else if (text.isNotBlank()) {
Timber.tag(TAG).v("Page $pageIndex: Inserting valid text ($cleanedLength chars).")
dao.insertPageText(PdfSearchIndex(bookId = bookId, pageIndex = pageIndex, content = text))
} else {
Timber.tag(TAG).i("Page $pageIndex: Text became empty after cleaning. Skipping insertion.")
}
} else {
Timber.tag(TAG).v("Page $pageIndex: No text found (Native or OCR).")
}
ocrUsed && text.isNotEmpty()
}
}
suspend fun getOrExtractText(
bookId: String,
document: PdfDocumentKt,
pageIndex: Int,
onModelDownloading: () -> Unit = {}
): String {
return withContext(Dispatchers.IO) {
val cachedText = dao.getPageText(bookId, pageIndex)
if (!cachedText.isNullOrBlank()) {
return@withContext cachedText
}
indexPage(bookId, document, pageIndex, onModelDownloading)
dao.getPageText(bookId, pageIndex) ?: ""
}
}
suspend fun hasNativeText(document: PdfDocumentKt, pageIndex: Int): Boolean {
return withContext(Dispatchers.IO) {
try {
document.openPage(pageIndex).use { page ->
page.openTextPage().use { textPage ->
textPage.textPageCountChars() > 0
}
}
} catch (_: Exception) {
false
}
}
}
suspend fun getOcrSearchRects(
document: PdfDocumentKt,
pageIndex: Int,
query: String,
onModelDownloading: () -> Unit = {}
): List<RectF> {
return withContext(Dispatchers.IO) {
val rects = mutableListOf<RectF>()
try {
document.openPage(pageIndex).use { page ->
val targetWidth = 1080
val ptrWidth = page.getPageWidthPoint()
val ptrHeight = page.getPageHeightPoint()
if (ptrWidth <= 0 || ptrHeight <= 0) return@use
val aspectRatio = ptrWidth.toFloat() / ptrHeight.toFloat()
val targetHeight = (targetWidth / aspectRatio).toInt().coerceAtLeast(1)
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
val visionText = OcrHelper.extractTextFromBitmap(bitmap, onModelDownloading)
visionText?.textBlocks?.forEach { block ->
block.lines.forEach { line ->
line.elements.forEach { element ->
if (element.text.contains(query, ignoreCase = true)) {
element.boundingBox?.let { box ->
val normalized = RectF(
box.left.toFloat() / targetWidth,
box.top.toFloat() / targetHeight,
box.right.toFloat() / targetWidth,
box.bottom.toFloat() / targetHeight
)
rects.add(normalized)
}
}
}
}
}
bitmap.recycle()
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to get OCR rects for page $pageIndex")
}
rects
}
}
suspend fun clearBookText(bookId: String) {
withContext(Dispatchers.IO) {
dao.clearBookText(bookId)
}
}
suspend fun clearAllText() {
withContext(Dispatchers.IO) {
dao.deleteAll()
}
}
private fun isAscii(string: String): Boolean {
return string.all { it.code < 128 }
}
private fun createPhraseRegex(query: String): Regex {
val clean = query.trim().replace("\"", "")
// Split by whitespace to handle user typing multiple spaces
val tokens = clean.split("\\s+".toRegex()).filter { it.isNotBlank() }
if (tokens.isEmpty()) return Regex("(?i)${Regex.escape(query)}") // Fallback
val isAscii = isAscii(clean)
val sb = StringBuilder("(?i)") // Case insensitive flag
// If ASCII, use word boundary at start.
if (isAscii) {
sb.append("\\b")
}
// Join tokens with \s+ to match any whitespace sequence in content
val escapedTokens = tokens.map { Regex.escape(it) }
sb.append(escapedTokens.joinToString("\\s+"))
return Regex(sb.toString())
}
fun searchBookSmart(bookId: String, query: String): Flow<SmartSearchResult> = flow {
val trimmed = query.trim()
if (trimmed.isBlank()) {
return@flow
}
// Use sanitized FTS query
val ftsQuery = generateFtsQuery(trimmed)
val pageMatchCount = dao.countMatches(bookId, ftsQuery)
val phraseRegex = createPhraseRegex(query)
if (pageMatchCount > 50) {
emit(SmartSearchResult.Paged(
pagingData = getSearchResultsPaged(bookId, query, phraseRegex),
totalPageCount = pageMatchCount
))
} else {
val rawMatches = dao.getAllMatches(bookId, ftsQuery)
val fullResults = mutableListOf<SearchResult>()
rawMatches.forEach { match ->
val regexMatches = phraseRegex.findAll(match.content)
var occurrenceIndex = 0
for (regexMatch in regexMatches) {
fullResults.add(
SearchResult(
locationInSource = match.pageIndex,
locationTitle = "Page ${match.pageIndex + 1}",
snippet = generateSnippet(match.content, regexMatch.range),
query = query,
occurrenceIndexInLocation = occurrenceIndex,
chunkIndex = match.pageIndex
)
)
occurrenceIndex++
}
}
emit(SmartSearchResult.Exact(fullResults))
}
}
private fun generateSnippet(content: String, matchRange: IntRange): AnnotatedString {
val snippetContextChars = 60
val start = (matchRange.first - snippetContextChars).coerceAtLeast(0)
val end = (matchRange.last + snippetContextChars).coerceAtMost(content.length)
val rawSnippet = content.substring(start, end)
// Adjust match indices relative to snippet
val matchStartInSnippet = matchRange.first - start
val matchEndInSnippet = matchRange.last - start
return buildAnnotatedString {
if (start > 0) append("...")
// Text before match
if (matchStartInSnippet > 0) {
append(rawSnippet.substring(0, matchStartInSnippet))
}
// The Match
pushStyle(SpanStyle(fontWeight = FontWeight.Bold, color = Color.Blue))
val actualMatchLength = (matchEndInSnippet - matchStartInSnippet + 1).coerceAtMost(rawSnippet.length - matchStartInSnippet)
if (actualMatchLength > 0) {
append(rawSnippet.substring(matchStartInSnippet, matchStartInSnippet + actualMatchLength))
}
pop()
// Text after match
if (matchEndInSnippet < rawSnippet.length - 1) {
append(rawSnippet.substring(matchEndInSnippet + 1))
}
if (end < content.length) append("...")
}
}
fun getSearchResultsPaged(bookId: String, query: String, regex: Regex? = null): Flow<PagingData<SearchResult>> {
val trimmed = query.trim()
if (trimmed.isBlank()) {
return kotlinx.coroutines.flow.flowOf(PagingData.empty())
}
val ftsQuery = generateFtsQuery(trimmed)
val phraseRegex = regex ?: createPhraseRegex(query)
return Pager(
config = PagingConfig(pageSize = 20, prefetchDistance = 10, enablePlaceholders = false)
) {
dao.searchBookPagingSource(bookId, ftsQuery)
}.flow.map { pagingData ->
pagingData.flatMap { match ->
val results = mutableListOf<SearchResult>()
val regexMatches = phraseRegex.findAll(match.content)
var occurrenceIndex = 0
for (regexMatch in regexMatches) {
results.add(
SearchResult(
locationInSource = match.pageIndex,
locationTitle = "Page ${match.pageIndex + 1}",
snippet = generateSnippet(match.content, regexMatch.range),
query = query,
occurrenceIndexInLocation = occurrenceIndex,
chunkIndex = match.pageIndex
)
)
occurrenceIndex++
}
results
}
}
}
suspend fun getNextResult(bookId: String, query: String, currentResult: SearchResult?): SearchResult? {
val trimmed = query.trim()
if (trimmed.isBlank()) return null
val ftsQuery = generateFtsQuery(trimmed)
val phraseRegex = createPhraseRegex(query)
val currentPageIndex = currentResult?.chunkIndex ?: -1
val currentOccurrenceIndex = currentResult?.occurrenceIndexInLocation ?: -1
// Check current page for next occurrence
if (currentPageIndex >= 0) {
val pageText = dao.getPageText(bookId, currentPageIndex)
if (pageText != null) {
val matches = phraseRegex.findAll(pageText).toList()
if (currentOccurrenceIndex + 1 < matches.size) {
val nextMatch = matches[currentOccurrenceIndex + 1]
return currentResult!!.copy(
occurrenceIndexInLocation = currentOccurrenceIndex + 1,
snippet = generateSnippet(pageText, nextMatch.range)
)
}
}
}
// Search subsequent pages
var searchPageIndex = currentPageIndex + 1
var attempts = 0
val maxAttempts = 50 // Limit linear scan depth to prevent UI freezes on sparse results
while(attempts < maxAttempts) {
val nextPageMatch = dao.getNextPageWithMatch(bookId, ftsQuery, searchPageIndex) ?: return null
val regexMatches = phraseRegex.findAll(nextPageMatch.content).toList()
if (regexMatches.isNotEmpty()) {
val firstMatch = regexMatches.first()
return SearchResult(
locationInSource = nextPageMatch.pageIndex,
locationTitle = "Page ${nextPageMatch.pageIndex + 1}",
snippet = generateSnippet(nextPageMatch.content, firstMatch.range),
query = query,
occurrenceIndexInLocation = 0,
chunkIndex = nextPageMatch.pageIndex
)
}
searchPageIndex = nextPageMatch.pageIndex + 1
attempts++
}
return null
}
suspend fun getPrevResult(bookId: String, query: String, currentResult: SearchResult?): SearchResult? {
val trimmed = query.trim()
if (trimmed.isBlank()) return null
val ftsQuery = generateFtsQuery(trimmed)
val phraseRegex = createPhraseRegex(query)
val currentPageIndex = currentResult?.chunkIndex ?: 0
val currentOccurrenceIndex = currentResult?.occurrenceIndexInLocation ?: 0
// Check current page for previous occurrence
if (currentPageIndex >= 0 && currentOccurrenceIndex > 0) {
val pageText = dao.getPageText(bookId, currentPageIndex)
if (pageText != null) {
val matches = phraseRegex.findAll(pageText).toList()
if (currentOccurrenceIndex - 1 < matches.size) {
val prevMatch = matches[currentOccurrenceIndex - 1]
return currentResult!!.copy(
occurrenceIndexInLocation = currentOccurrenceIndex - 1,
snippet = generateSnippet(pageText, prevMatch.range)
)
}
}
}
// Search previous pages
var searchPageIndex = currentPageIndex - 1
var attempts = 0
val maxAttempts = 50
while (attempts < maxAttempts && searchPageIndex >= 0) {
val prevPageMatch = dao.getPrevPageWithMatch(bookId, ftsQuery, searchPageIndex) ?: return null
val regexMatches = phraseRegex.findAll(prevPageMatch.content).toList()
if (regexMatches.isNotEmpty()) {
val lastMatch = regexMatches.last()
return SearchResult(
locationInSource = prevPageMatch.pageIndex,
locationTitle = "Page ${prevPageMatch.pageIndex + 1}",
snippet = generateSnippet(prevPageMatch.content, lastMatch.range),
query = query,
occurrenceIndexInLocation = regexMatches.size - 1,
chunkIndex = prevPageMatch.pageIndex
)
}
searchPageIndex = prevPageMatch.pageIndex - 1
attempts++
}
return null
}
}

View file

@ -0,0 +1,223 @@
// BaseTtsSynthesizer.kt
package com.aryan.reader.tts
import android.content.Context
import android.os.Bundle
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import timber.log.Timber
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.withTimeout
import java.io.File
import java.util.Locale
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlinx.coroutines.delay
private const val START_TIMEOUT_FAST_MS = 750L
private const val START_TIMEOUT_RETRY_MS = 2500L
private const val PROCESS_TIMEOUT_MS = 4000L
private const val MAX_RETRY_ATTEMPTS = 3
class BaseTtsSynthesizer(private val context: Context) {
private var tts: TextToSpeech? = null
private var isInitialized = false
private val mutex = Mutex()
private data class RequestContext(
val resultDeferred: CompletableDeferred<Pair<File?, String?>>,
val startSignal: CompletableDeferred<Unit>,
val file: File,
val text: String
)
private val requests = ConcurrentHashMap<String, RequestContext>()
private val sharedListener = object : UtteranceProgressListener() {
override fun onStart(utteranceId: String?) {
Timber.d("BaseTts: onStart $utteranceId [Thread: ${Thread.currentThread().name}]")
utteranceId?.let { id ->
requests[id]?.startSignal?.complete(Unit)
}
}
override fun onDone(utteranceId: String?) {
utteranceId?.let { id ->
val req = requests.remove(id)
if (req != null) {
Timber.d("BaseTts: onDone $id. [Thread: ${Thread.currentThread().name}]")
req.resultDeferred.complete(Pair(req.file, req.text))
}
}
}
@Suppress("OVERRIDE_DEPRECATION")
override fun onError(utteranceId: String?) {
onError(utteranceId, -1)
}
override fun onError(utteranceId: String?, errorCode: Int) {
Timber.e("BaseTts: onError $utteranceId code=$errorCode [Thread: ${Thread.currentThread().name}]")
utteranceId?.let { id ->
val req = requests.remove(id)
req?.resultDeferred?.complete(Pair(null, null))
}
}
}
suspend fun initialize() {
mutex.withLock {
if (!isInitialized) {
initializeEngineLocked()
}
}
}
private suspend fun initializeEngineLocked() {
if (isInitialized) return
return suspendCancellableCoroutine { continuation ->
Timber.d("BaseTts: Initializing TextToSpeech engine...")
// Use Application Context to prevent memory leaks and detachment issues
tts = TextToSpeech(context.applicationContext) { status ->
if (status == TextToSpeech.SUCCESS) {
isInitialized = true
Timber.d("TextToSpeech engine initialized successfully.")
try {
val result = tts?.setLanguage(Locale.getDefault())
if (result == TextToSpeech.LANG_MISSING_DATA || result == TextToSpeech.LANG_NOT_SUPPORTED) {
Timber.e("Default language not supported/missing data")
}
} catch (e: Exception) {
Timber.e(e, "Error setting language")
}
tts?.setOnUtteranceProgressListener(sharedListener)
if (continuation.isActive) continuation.resume(Unit)
} else {
Timber.e("Failed to initialize TextToSpeech engine. Status: $status")
if (continuation.isActive) continuation.resumeWithException(IllegalStateException("TTS initialization failed"))
}
}
}
}
private suspend fun shutdownEngineLocked() {
Timber.w("BaseTts: Shutting down TTS engine for recovery.")
try {
requests.clear()
tts?.stop()
tts?.shutdown()
} catch (e: Exception) {
Timber.e(e, "Error shutting down TTS")
} finally {
tts = null
isInitialized = false
// COOL-DOWN: Critical delay to allow OS Service to unbind/reset before we try to init again.
delay(350)
}
}
suspend fun synthesizeToFile(text: String): Pair<File?, String?> {
if (text.isBlank()) {
return Pair(null, text)
}
return mutex.withLock {
var result: Pair<File?, String?> = Pair(null, null)
for (attempt in 1..MAX_RETRY_ATTEMPTS) {
val utteranceId = UUID.randomUUID().toString()
val tempFile = File.createTempFile("base_tts_", ".wav", context.cacheDir)
// Prepare signals
val resultDeferred = CompletableDeferred<Pair<File?, String?>>()
val startSignal = CompletableDeferred<Unit>()
try {
if (!isInitialized) {
try {
initializeEngineLocked()
} catch (e: Exception) {
Timber.e(e, "BaseTts: Init failed on attempt $attempt")
if (attempt == MAX_RETRY_ATTEMPTS) return@withLock Pair(null, null)
delay(200)
continue
}
}
Timber.d("BaseTts: Requesting synthesis (Attempt $attempt). ID: $utteranceId")
requests[utteranceId] = RequestContext(resultDeferred, startSignal, tempFile, text)
// Prevention: No tts?.stop() here.
val ttsResult = tts?.synthesizeToFile(text, Bundle.EMPTY, tempFile, utteranceId)
if (ttsResult == TextToSpeech.ERROR) {
Timber.e("synthesizeToFile returned immediate ERROR for $utteranceId.")
requests.remove(utteranceId)
throw IllegalStateException("TTS Engine returned ERROR")
}
val startTimeout = if (attempt == 1) START_TIMEOUT_FAST_MS else START_TIMEOUT_RETRY_MS
try {
withTimeout(startTimeout) {
startSignal.await()
}
} catch (_: TimeoutCancellationException) {
Timber.w("BaseTts: ZOMBIE DETECTED. onStart not received within ${startTimeout}ms.")
throw ZombieEngineException()
}
try {
val finalResult = withTimeout(PROCESS_TIMEOUT_MS) {
resultDeferred.await()
}
if (finalResult.first != null) {
result = finalResult
break // Success!
} else {
Timber.w("BaseTts: onError received during processing.")
throw IllegalStateException("TTS Engine reported onError")
}
} catch (_: TimeoutCancellationException) {
Timber.w("BaseTts: PROCESSING STUCK. onDone not received within ${PROCESS_TIMEOUT_MS}ms.")
throw IllegalStateException("Processing Timeout")
}
} catch (e: Exception) {
Timber.w("BaseTts: Failure on attempt $attempt. Reason: ${e.message}")
tempFile.delete()
requests.remove(utteranceId)
if (attempt < MAX_RETRY_ATTEMPTS) {
shutdownEngineLocked()
}
}
}
result
}
}
fun shutdown() {
requests.clear()
tts?.stop()
tts?.shutdown()
isInitialized = false
Timber.d("TextToSpeech engine shut down.")
}
private class ZombieEngineException : Exception("Engine failed to start")
}

View file

@ -0,0 +1,284 @@
// TtsController.kt
package com.aryan.reader.tts
import android.content.ComponentName
import android.content.Context
import android.os.Bundle
import timber.log.Timber
import androidx.annotation.OptIn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
import com.google.common.util.concurrent.ListenableFuture
import com.google.common.util.concurrent.MoreExecutors
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
private const val SETTINGS_PREFS_NAME = "epub_reader_settings"
private const val TTS_SPEAKER_KEY = "tts_speaker"
private fun saveSpeaker(context: Context, speakerId: String) {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
prefs.edit { putString(TTS_SPEAKER_KEY, speakerId) }
}
private fun loadSpeaker(context: Context): String {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getString(TTS_SPEAKER_KEY, DEFAULT_SPEAKER_ID) ?: DEFAULT_SPEAKER_ID
}
@UnstableApi
class TtsController(context: Context) : Player.Listener {
private val context = context.applicationContext
private val _ttsState = MutableStateFlow(TtsState())
val ttsState = _ttsState.asStateFlow()
private var mediaController: MediaController? = null
private var controllerFuture: ListenableFuture<MediaController>? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var pollingJob: Job? = null
init {
val initialSpeakerId = loadSpeaker(this.context)
_ttsState.value = _ttsState.value.copy(speakerId = initialSpeakerId)
}
fun connect() {
if (mediaController != null || controllerFuture != null) return
val sessionToken = SessionToken(context, ComponentName(context, TtsService::class.java))
val future = MediaController.Builder(context, sessionToken).buildAsync()
controllerFuture = future
future.addListener(
{
try {
if (future.isCancelled) return@addListener
val controller = future.get()
if (controllerFuture != future) {
Timber.d("MediaController connected after release. Releasing immediately.")
controller.release()
return@addListener
}
mediaController = controller
controllerFuture = null
mediaController?.addListener(this)
Timber.d("MediaController connected.")
updateStateFromController()
startPolling()
} catch (e: Exception) {
Timber.w("Failed to connect MediaController: ${e.message}")
if (controllerFuture == future) {
controllerFuture = null
}
}
},
MoreExecutors.directExecutor()
)
}
private fun startPolling() {
if (pollingJob?.isActive == true) return
pollingJob = scope.launch {
while (isActive) {
updateStateFromController()
delay(150)
}
}
}
fun start(
chunks: List<com.aryan.reader.paginatedreader.TtsChunk>,
bookTitle: String,
chapterTitle: String?,
coverImageUri: String?,
ttsMode: String,
playbackSource: String = "READER"
) {
if (chunks.isEmpty()) {
Timber.w("TtsController: start called with empty chunks!")
return
}
Timber.d("UI sending START command with mode: $ttsMode")
Timber.d("TtsController: Sending START command. Chunk count: ${chunks.size}. Mode: $ttsMode. First chunk len: ${chunks.first().text.length}")
val textList = ArrayList(chunks.map { it.text })
val cfiList = ArrayList(chunks.map { it.sourceCfi })
val offsetList = ArrayList(chunks.map { it.startOffsetInSource })
val args = Bundle().apply {
putStringArrayList(KEY_TEXT_CHUNKS, textList)
putStringArrayList(KEY_SOURCE_CFIS, cfiList)
putIntegerArrayList(KEY_START_OFFSETS, offsetList)
putString(KEY_SPEAKER_ID, _ttsState.value.speakerId)
putString(KEY_BOOK_TITLE, bookTitle)
putString(KEY_CHAPTER_TITLE, chapterTitle)
putString(KEY_COVER_IMAGE_URI, coverImageUri)
putString(KEY_TTS_MODE, ttsMode)
putString(KEY_PLAYBACK_SOURCE, playbackSource)
}
mediaController?.sendCustomCommand(START_TTS_COMMAND, args)
val metadataBuilder = androidx.media3.common.MediaMetadata.Builder()
.setArtist(bookTitle)
.setTitle(chapterTitle ?: "Reading Aloud")
coverImageUri?.let { metadataBuilder.setArtworkUri(it.toUri()) }
val metadata = MediaItem.Builder()
.setMediaId("tts_session")
.setMediaMetadata(metadataBuilder.build())
.build()
mediaController?.setMediaItem(metadata)
}
fun pause() {
mediaController?.pause()
}
fun resume() {
mediaController?.play()
}
fun stop() {
Timber.d("UI sending STOP command.")
mediaController?.sendCustomCommand(STOP_TTS_COMMAND, Bundle.EMPTY)
}
@Suppress("unused")
fun changeSpeaker(speakerId: String) {
Timber.d("UI sending CHANGE_SPEAKER command.")
saveSpeaker(context, speakerId)
_ttsState.value = _ttsState.value.copy(speakerId = speakerId)
val args = Bundle().apply {
putString(KEY_SPEAKER_ID, speakerId)
}
mediaController?.sendCustomCommand(CHANGE_SPEAKER_COMMAND, args)
}
@Suppress("unused")
fun changeTtsMode(mode: String) {
Timber.d("UI sending CHANGE_TTS_MODE command.")
val args = Bundle().apply {
putString(KEY_TTS_MODE, mode)
}
mediaController?.sendCustomCommand(CHANGE_TTS_MODE_COMMAND, args)
}
override fun onEvents(player: Player, events: Player.Events) {
updateStateFromController()
}
private fun updateStateFromController() {
mediaController?.let { controller ->
val customState = controller.customLayout.firstOrNull()?.extras ?: Bundle.EMPTY
val currentMediaItem = controller.currentMediaItem
val currentTextFromMediaItem = currentMediaItem?.mediaMetadata?.subtitle?.toString()
val isPlaybackActive = controller.isPlaying || controller.playbackState == Player.STATE_READY || controller.playbackState == Player.STATE_BUFFERING
val serviceSpeaker = customState.getString("speakerId", _ttsState.value.speakerId)
val sessionEndedByStop = customState.getBoolean("sessionEndedByStop", false)
val isLoading = customState.getBoolean("isLoading", false)
val isChangingConfig = customState.getBoolean("isChangingConfig", false)
val sessionFinished = customState.getBoolean("sessionFinished", false)
val playbackSource = customState.getString("playbackSource")
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
val sourceCfi = mediaItemExtras?.getString("sourceCfi")
val startOffset = mediaItemExtras?.getInt("startOffset", -1) ?: -1
val currentWordSourceCfi = customState.getString("currentWordSourceCfi")
val currentWordStartOffset = customState.getInt("currentWordStartOffset", -1)
val currentState = _ttsState.value
_ttsState.value = currentState.copy(
isPlaying = controller.isPlaying,
isLoading = isLoading,
currentText = if (isPlaybackActive) {
currentTextFromMediaItem ?: customState.getString("currentText")
} else {
if (isLoading) currentState.currentText else null
},
errorMessage = customState.getString("errorMessage"),
speakerId = serviceSpeaker,
sourceCfi = if (isPlaybackActive) {
sourceCfi
} else {
if (isLoading) currentState.sourceCfi else null
},
startOffsetInSource = if (isPlaybackActive) {
startOffset
} else {
if (isLoading) currentState.startOffsetInSource else -1
},
playbackState = controller.playbackState,
sessionEndedByStop = sessionEndedByStop,
currentWordSourceCfi = if (isPlaybackActive) currentWordSourceCfi else null,
currentWordStartOffset = if (isPlaybackActive) currentWordStartOffset else -1,
isChangingConfig = isChangingConfig,
sessionFinished = sessionFinished,
playbackSource = playbackSource
)
}
}
fun release() {
pollingJob?.cancel()
scope.cancel()
val future = controllerFuture
controllerFuture = null
if (future != null && !future.isDone) {
future.cancel(true)
}
mediaController?.removeListener(this)
mediaController?.release()
mediaController = null
Timber.d("MediaController released.")
}
}
@OptIn(UnstableApi::class)
@Composable
fun rememberTtsController(): TtsController {
val context = LocalContext.current
val controller = remember {
TtsController(context)
}
LaunchedEffect(controller) {
controller.connect()
}
DisposableEffect(controller) {
onDispose {
controller.release()
}
}
return controller
}

View file

@ -0,0 +1,629 @@
// TtsPlaybackManager.kt
package com.aryan.reader.tts
import android.net.Uri
import android.os.Bundle
import timber.log.Timber
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import com.aryan.reader.R
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import androidx.core.net.toUri
import com.aryan.reader.paginatedreader.TimedWord
import com.aryan.reader.paginatedreader.TtsChunk
import kotlinx.coroutines.delay
val START_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.START", Bundle.EMPTY)
val STOP_TTS_COMMAND = SessionCommand("com.aryan.reader.tts.STOP", Bundle.EMPTY)
val CHANGE_SPEAKER_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_SPEAKER", Bundle.EMPTY)
private val STATE_UPDATE_COMMAND = SessionCommand("com.aryan.reader.tts.STATE_UPDATE", Bundle.EMPTY)
val CHANGE_TTS_MODE_COMMAND = SessionCommand("com.aryan.reader.tts.CHANGE_MODE", Bundle.EMPTY)
const val KEY_TEXT_CHUNKS = "KEY_TEXT_CHUNKS"
const val KEY_SOURCE_CFIS = "KEY_SOURCE_CFIS"
const val KEY_START_OFFSETS = "KEY_START_OFFSETS"
const val KEY_SPEAKER_ID = "KEY_SPEAKER_ID"
const val KEY_BOOK_TITLE = "KEY_BOOK_TITLE"
const val KEY_CHAPTER_TITLE = "KEY_CHAPTER_TITLE"
const val KEY_COVER_IMAGE_URI = "KEY_COVER_IMAGE_URI"
const val KEY_TTS_MODE = "KEY_TTS_MODE"
const val KEY_WORD_TIMESTAMPS = "KEY_WORD_TIMESTAMPS"
const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
private const val PREFETCH_LOOKAHEAD = 2
@UnstableApi
class TtsPlaybackManager(
private val player: Player,
private val generateAudioChunk: suspend (textChunk: String, speakerId: String, mode: TtsMode) -> TtsAudioData
) : MediaSession.Callback, Player.Listener {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private var mediaSession: MediaSession? = null
private val prefetchingJobs = mutableMapOf<Int, Job>()
private var wordTrackingJob: Job? = null
private var preparationJob: Job? = null
private var isChangingConfig = false
enum class TtsMode {
CLOUD, BASE
}
data class TtsState(
val isPlaying: Boolean = false,
val isLoading: Boolean = false,
val currentText: String? = null,
val errorMessage: String? = null,
val speakerId: String = DEFAULT_SPEAKER_ID,
val sourceCfi: String? = null,
val startOffsetInSource: Int = -1,
val playbackState: Int = Player.STATE_IDLE,
val sessionEndedByStop: Boolean = false,
val currentWordSourceCfi: String? = null,
val currentWordStartOffset: Int = -1,
val isChangingConfig: Boolean = false,
val sessionFinished: Boolean = false,
val playbackSource: String? = null
)
private val _ttsState = MutableStateFlow(TtsState())
private var textChunks: List<TtsChunk> = emptyList()
private var audioFiles: MutableMap<Int, File> = mutableMapOf()
private var currentSpeakerId = DEFAULT_SPEAKER_ID
private var bookTitle: String? = null
private var chapterTitle: String? = null
private var coverImageUri: String? = null
private var currentTtsMode = TtsMode.CLOUD
init {
player.addListener(this)
_ttsState.onEach { newState ->
mediaSession?.let { session ->
val layout = listOf(
createStateButton(newState),
createStopCommandButton()
)
session.setCustomLayout(layout)
}
}.launchIn(scope)
}
fun setMediaSession(session: MediaSession) {
this.mediaSession = session
}
override fun onConnect(
session: MediaSession,
controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
val availableSessionCommands = MediaSession.ConnectionResult.DEFAULT_SESSION_COMMANDS.buildUpon()
.add(START_TTS_COMMAND)
.add(STOP_TTS_COMMAND)
.add(CHANGE_SPEAKER_COMMAND)
.add(CHANGE_TTS_MODE_COMMAND)
.build()
val availablePlayerCommands = MediaSession.ConnectionResult.DEFAULT_PLAYER_COMMANDS.buildUpon()
.remove(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.remove(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.remove(Player.COMMAND_SEEK_TO_NEXT)
.remove(Player.COMMAND_SEEK_TO_PREVIOUS)
.build()
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(availableSessionCommands)
.setAvailablePlayerCommands(availablePlayerCommands)
.build()
}
override fun onAddMediaItems(
mediaSession: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItems: List<MediaItem>
): ListenableFuture<List<MediaItem>> {
return Futures.immediateFuture(mediaItems)
}
override fun onCustomCommand(
session: MediaSession,
controller: MediaSession.ControllerInfo,
customCommand: SessionCommand,
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand) {
START_TTS_COMMAND -> {
val chunks = args.getStringArrayList(KEY_TEXT_CHUNKS) ?: emptyList()
Timber.d("TtsService: START command received. Size: ${chunks.size}")
val cfis = args.getStringArrayList(KEY_SOURCE_CFIS)
val offsets = args.getIntegerArrayList(KEY_START_OFFSETS)
val speakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
val bookTitle = args.getString(KEY_BOOK_TITLE)
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
val richChunks = if (cfis != null && offsets != null && chunks.size == cfis.size && chunks.size == offsets.size) {
chunks.mapIndexed { index, text ->
TtsChunk(text, cfis[index], offsets[index])
}
} else {
chunks.map { TtsChunk(it, "", -1) }
}
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, ttsMode, playbackSource)
}
STOP_TTS_COMMAND -> {
Timber.d("Received STOP command.")
handleStopTts(userInitiated = true)
}
CHANGE_SPEAKER_COMMAND -> {
val newSpeakerId = args.getString(KEY_SPEAKER_ID, DEFAULT_SPEAKER_ID)
handleChangeSpeaker(newSpeakerId)
}
CHANGE_TTS_MODE_COMMAND -> {
val newModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val newMode = try { TtsMode.valueOf(newModeName) } catch (_: Exception) { TtsMode.CLOUD }
handleChangeTtsMode(newMode)
}
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
private fun handleChangeTtsMode(newMode: TtsMode) {
if (currentTtsMode == newMode) return
preparationJob?.cancel()
isChangingConfig = true
val wasPlaying = player.isPlaying
val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull()
val currentPosition = player.currentPosition
currentTtsMode = newMode
if (textChunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(isPlaying = false)
isChangingConfig = false
return
}
_ttsState.value = _ttsState.value.copy(isLoading = true, isPlaying = false, isChangingConfig = true)
player.stop()
player.clearMediaItems()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
scope.launch {
clearAudioFiles()
}
preparationJob = scope.launch {
val startIndex = currentChunkIndex ?: 0
prepareAndPlayFirstChunk(startAtIndex = startIndex, playWhenReady = wasPlaying, startAtPosition = currentPosition)
}
}
private fun handleStartTts(
chunks: List<TtsChunk>,
speakerId: String,
bookTitle: String?,
chapterTitle: String?,
coverImageUri: String?,
ttsMode: TtsMode,
playbackSource: String?
) {
if (isChangingConfig) {
Timber.w("Ignoring START command because a config change is already in progress.")
return
}
if (chunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(errorMessage = "No text to read.")
return
}
handleStopTts(clearState = false)
textChunks = chunks
currentSpeakerId = speakerId
currentTtsMode = ttsMode
this.bookTitle = bookTitle
this.chapterTitle = chapterTitle
this.coverImageUri = coverImageUri
_ttsState.value = TtsState(isLoading = true, speakerId = speakerId, playbackSource = playbackSource)
preparationJob = scope.launch {
prepareAndPlayFirstChunk()
}
}
private fun handleChangeSpeaker(newSpeakerId: String) {
if (currentSpeakerId == newSpeakerId) return
preparationJob?.cancel()
isChangingConfig = true
val wasPlaying = player.isPlaying
val currentChunkIndex = player.currentMediaItem?.mediaId?.toIntOrNull()
val currentPosition = player.currentPosition
currentSpeakerId = newSpeakerId
if (textChunks.isEmpty()) {
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId)
isChangingConfig = false
return
}
_ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId, isLoading = true, isPlaying = false, isChangingConfig = true)
player.stop()
player.clearMediaItems()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
scope.launch {
clearAudioFiles()
}
preparationJob = scope.launch {
val startIndex = currentChunkIndex ?: 0
prepareAndPlayFirstChunk(startAtIndex = startIndex, playWhenReady = wasPlaying, startAtPosition = currentPosition)
}
}
private suspend fun prepareAndPlayFirstChunk(startAtIndex: Int = 0, playWhenReady: Boolean = true, startAtPosition: Long = 0L) {
val firstChunk = textChunks.getOrNull(startAtIndex)
if (firstChunk == null) {
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Error starting playback.", isChangingConfig = false)
withContext(Dispatchers.Main) { isChangingConfig = false }
return
}
val ttsAudioData = generateAudioChunk(firstChunk.text, currentSpeakerId, currentTtsMode)
val audioFile = ttsAudioData.audioFile
val serverText = ttsAudioData.serverText
if (audioFile != null && serverText != null) {
audioFiles[startAtIndex] = audioFile
val updatedChunk = processWordTimings(firstChunk, serverText, ttsAudioData.wordTimings)
val mutableChunks = textChunks.toMutableList()
mutableChunks[startAtIndex] = updatedChunk
textChunks = mutableChunks.toList()
val mediaItem = createMediaItem(serverText, audioFile.absolutePath, startAtIndex, updatedChunk)
withContext(Dispatchers.Main) {
player.setMediaItem(mediaItem)
player.prepare()
if (startAtPosition > 0) {
player.seekTo(startAtPosition)
}
player.playWhenReady = playWhenReady
isChangingConfig = false
_ttsState.value = _ttsState.value.copy(
isLoading = false,
isPlaying = playWhenReady,
currentText = serverText,
sourceCfi = updatedChunk.sourceCfi,
startOffsetInSource = updatedChunk.startOffsetInSource,
isChangingConfig = false
)
}
prefetchNextChunkAudio(startAtIndex)
} else {
withContext(Dispatchers.Main) { isChangingConfig = false }
_ttsState.value = _ttsState.value.copy(isLoading = false, errorMessage = "Failed to load audio.", isChangingConfig = false)
}
}
private fun processWordTimings(
originalChunk: TtsChunk,
@Suppress("unused") serverText: String,
wordTimings: List<WordTimingInfo>?
): TtsChunk {
if (wordTimings.isNullOrEmpty()) {
return originalChunk
}
val timedWords = mutableListOf<TimedWord>()
var currentSearchIndex = 0
wordTimings.forEach { timingInfo ->
val wordIndex = originalChunk.text.indexOf(timingInfo.word, startIndex = currentSearchIndex, ignoreCase = false)
if (wordIndex != -1) {
timedWords.add(
TimedWord(
word = timingInfo.word,
startTime = timingInfo.startTime,
startOffset = originalChunk.startOffsetInSource + wordIndex
)
)
currentSearchIndex = wordIndex + timingInfo.word.length
} else {
Timber.w("Could not find server word '${timingInfo.word}' in original chunk text")
}
}
return originalChunk.copy(timedWords = timedWords)
}
private fun handleStopTts(clearState: Boolean = true, userInitiated: Boolean = false) {
preparationJob?.cancel()
wordTrackingJob?.cancel()
if (clearState) {
val finalState = TtsState(sessionEndedByStop = userInitiated)
_ttsState.value = finalState
mediaSession?.let { session ->
val layout = listOf(
createStateButton(finalState),
createStopCommandButton()
)
session.setCustomLayout(layout)
}
}
player.stop()
player.clearMediaItems()
textChunks = emptyList()
prefetchingJobs.values.forEach { it.cancel() }
prefetchingJobs.clear()
scope.launch {
clearAudioFiles()
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
val newPlaylistIndex = player.currentMediaItemIndex
if (newPlaylistIndex == C.INDEX_UNSET) return
val currentChunkIndex = mediaItem?.mediaId?.toIntOrNull() ?: return
val newText = mediaItem.mediaMetadata.subtitle?.toString()
val extras = mediaItem.mediaMetadata.extras
val sourceCfi = extras?.getString("sourceCfi")
val startOffset = extras?.getInt("startOffset", -1) ?: -1
_ttsState.value = _ttsState.value.copy(
currentText = newText,
sourceCfi = sourceCfi,
startOffsetInSource = startOffset
)
wordTrackingJob?.cancel()
if (player.isPlaying) {
wordTrackingJob = scope.launch {
trackWordByWord()
}
}
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO && newPlaylistIndex > 0) {
val previousMediaItem = player.getMediaItemAt(newPlaylistIndex - 1)
val previousChunkIndex = previousMediaItem.mediaId.toIntOrNull()
if (previousChunkIndex != null) {
scope.launch {
audioFiles.remove(previousChunkIndex)?.delete()
}
}
}
prefetchNextChunkAudio(currentChunkIndex)
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
var nextState = _ttsState.value.copy(isPlaying = isPlaying)
if (isPlaying) {
if (nextState.isLoading) {
nextState = nextState.copy(isLoading = false)
}
wordTrackingJob?.cancel()
wordTrackingJob = scope.launch {
trackWordByWord()
}
} else {
wordTrackingJob?.cancel()
nextState = nextState.copy(
currentWordSourceCfi = null,
currentWordStartOffset = -1
)
val currentChunkIndex = player.currentMediaItemIndex
val isLastChunkInSession = textChunks.isNotEmpty() && currentChunkIndex == textChunks.size - 1
if (player.playbackState == Player.STATE_ENDED) {
if (isLastChunkInSession || textChunks.isEmpty()) {
nextState = nextState.copy(sessionFinished = true)
} else {
// Check if prefetch is active for the next chunk
val nextIdx = currentChunkIndex + 1
val isPrefetching = prefetchingJobs.containsKey(nextIdx)
if (!isPrefetching) {
Timber.w("BUFFERING: Stalled at chunk $currentChunkIndex. Restarting prefetch for $nextIdx.")
prefetchNextChunkAudio(currentChunkIndex)
}
nextState = nextState.copy(isLoading = true)
}
}
}
_ttsState.value = nextState
if (!isPlaying && player.playbackState == Player.STATE_IDLE) {
if (isChangingConfig) {
return
}
if (!nextState.sessionEndedByStop) {
handleStopTts(userInitiated = true)
}
}
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Timber.e(error, "Player error: ${error.message}")
_ttsState.value = _ttsState.value.copy(errorMessage = "Playback error: ${error.message}")
handleStopTts(userInitiated = true)
}
private fun prefetchNextChunkAudio(currentIndex: Int) {
for (i in 1..PREFETCH_LOOKAHEAD) {
val targetIndex = currentIndex + i
if (targetIndex < textChunks.size) {
if (prefetchingJobs.containsKey(targetIndex)) {
continue
}
if (audioFiles.containsKey(targetIndex)) {
continue
}
Timber.d("PlaybackManager: Scheduling prefetch for chunk $targetIndex")
val job = scope.launch {
val nextChunk = textChunks[targetIndex]
val ttsAudioData = generateAudioChunk(nextChunk.text, currentSpeakerId, currentTtsMode)
val audioFile = ttsAudioData.audioFile
val serverText = ttsAudioData.serverText
if (audioFile != null && serverText != null) {
audioFiles[targetIndex] = audioFile
val updatedChunk = processWordTimings(nextChunk, serverText, ttsAudioData.wordTimings)
val mutableChunks = textChunks.toMutableList()
mutableChunks[targetIndex] = updatedChunk
textChunks = mutableChunks.toList()
val nextMediaItem = createMediaItem(serverText, audioFile.absolutePath, targetIndex, updatedChunk)
withContext(Dispatchers.Main) {
val wasLoading = _ttsState.value.isLoading
var exists = false
for (k in 0 until player.mediaItemCount) {
if (player.getMediaItemAt(k).mediaId == targetIndex.toString()) {
exists = true
break
}
}
if (!exists) {
player.addMediaItem(nextMediaItem)
}
if (player.playbackState == Player.STATE_ENDED && player.playWhenReady && targetIndex == player.currentMediaItemIndex + 1) {
player.seekToNextMediaItem()
player.play()
} else if (wasLoading && targetIndex == player.currentMediaItemIndex + 1) {
_ttsState.value = _ttsState.value.copy(isLoading = false)
}
}
} else {
Timber.e("Prefetch: Failed to download chunk $targetIndex")
}
}
prefetchingJobs[targetIndex] = job
job.invokeOnCompletion {
prefetchingJobs.remove(targetIndex)
}
}
}
}
private suspend fun trackWordByWord() {
while (true) {
val currentMediaItem = withContext(Dispatchers.Main) { player.currentMediaItem } ?: break
val playbackPosition = withContext(Dispatchers.Main) { player.currentPosition }
val extras = currentMediaItem.mediaMetadata.extras ?: break
val timestamps = extras.getDoubleArray(KEY_WORD_TIMESTAMPS) ?: break
val offsets = extras.getIntArray(KEY_WORD_OFFSETS) ?: break
val sourceCfi = extras.getString("sourceCfi") ?: break
val currentWordIndex = timestamps.indexOfLast { (it * 1000).toLong() <= playbackPosition }
if (currentWordIndex != -1) {
val currentWordOffset = offsets[currentWordIndex]
if (_ttsState.value.currentWordStartOffset != currentWordOffset || _ttsState.value.currentWordSourceCfi != sourceCfi) {
_ttsState.value = _ttsState.value.copy(
currentWordSourceCfi = sourceCfi,
currentWordStartOffset = currentWordOffset
)
}
}
delay(100)
}
}
private fun createMediaItem(text: String, path: String, index: Int, chunk: TtsChunk): MediaItem {
val extras = Bundle().apply {
putString("sourceCfi", chunk.sourceCfi)
putInt("startOffset", chunk.startOffsetInSource)
if (chunk.timedWords.isNotEmpty()) {
val timestamps = chunk.timedWords.map { it.startTime }.toDoubleArray()
val offsets = chunk.timedWords.map { it.startOffset }.toIntArray()
putDoubleArray(KEY_WORD_TIMESTAMPS, timestamps)
putIntArray(KEY_WORD_OFFSETS, offsets)
}
}
val metadata = MediaMetadata.Builder()
.setArtist(bookTitle)
.setTitle(chapterTitle)
.setSubtitle(text)
.setArtworkUri(coverImageUri?.toUri())
.setTrackNumber(index + 1)
.setTotalTrackCount(textChunks.size)
.setExtras(extras)
.build()
return MediaItem.Builder()
.setUri(Uri.fromFile(File(path)))
.setMediaId(index.toString())
.setMediaMetadata(metadata)
.build()
}
private suspend fun clearAudioFiles() {
withContext(Dispatchers.IO) {
audioFiles.values.forEach { it.delete() }
audioFiles.clear()
}
}
@Suppress("Deprecation")
private fun createStateButton(state: TtsState): CommandButton {
val bundle = Bundle().apply {
putBoolean("isLoading", state.isLoading)
putString("currentText", state.currentText)
putString("errorMessage", state.errorMessage)
putString("speakerId", state.speakerId)
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
putString("currentWordSourceCfi", state.currentWordSourceCfi)
putInt("currentWordStartOffset", state.currentWordStartOffset)
putBoolean("isChangingConfig", state.isChangingConfig)
putBoolean("sessionFinished", state.sessionFinished)
putString("playbackSource", state.playbackSource)
}
return CommandButton.Builder()
.setSessionCommand(STATE_UPDATE_COMMAND)
.setDisplayName("TtsState")
.setExtras(bundle)
.build()
}
@Suppress("Deprecation")
private fun createStopCommandButton(): CommandButton {
return CommandButton.Builder()
.setDisplayName("Stop TTS")
.setSessionCommand(STOP_TTS_COMMAND)
.setIconResId(R.drawable.close)
.build()
}
fun release() {
player.removeListener(this)
handleStopTts(userInitiated = true)
Timber.d("TtsPlaybackManager released.")
}
}

View file

@ -0,0 +1,237 @@
// TtsService.kt
package com.aryan.reader.tts
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.util.Base64
import timber.log.Timber
import androidx.core.content.ContextCompat
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import com.aryan.reader.tts.TtsPlaybackManager.TtsMode
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.net.URL
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONArray
data class WordTimingInfo(val word: String, val startTime: Double)
data class TtsAudioData(
val audioFile: File?,
val serverText: String?,
val wordTimings: List<WordTimingInfo>?
)
data class PageCharacterRange(
val pageInChapter: Int,
val cfi: String,
val startOffset: Int,
val endOffset: Int
)
@UnstableApi
class TtsService : MediaSessionService() {
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var mediaSession: MediaSession? = null
private lateinit var player: ExoPlayer
private lateinit var playbackManager: TtsPlaybackManager
private lateinit var baseTtsSynthesizer: BaseTtsSynthesizer
override fun onUpdateNotification(session: MediaSession, startInForegroundRequired: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
if (startInForegroundRequired) {
stopSelf()
}
return
}
super.onUpdateNotification(session, startInForegroundRequired)
}
/**
* Generic function to download TTS audio from a server endpoint.
* This is used for both the self-hosted server and the Google Cloud worker.
*
* @param chunkToSpeak The text to synthesize.
* @param speakerId The identifier for the voice.
* @param serverUrl The base URL of the TTS server.
* @param audioFileExtension The file extension for the temporary audio file (e.g., ".flac", ".mp3").
* @return A pair containing the temporary audio file and the text chunk returned by the server, or null if it fails.
*/
private suspend fun downloadFromTtsServer(
chunkToSpeak: String,
speakerId: String,
serverUrl: String,
audioFileExtension: String
): TtsAudioData {
if (chunkToSpeak.isBlank()) {
return TtsAudioData(null, null, null)
}
return withContext(Dispatchers.IO) {
var tempAudioFile: File? = null
try {
val url = URL(serverUrl)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
connection.setRequestProperty("Accept", "application/json")
connection.connectTimeout = 15000
connection.readTimeout = 60000
connection.doOutput = true
connection.doInput = true
val jsonPayload = JSONObject()
jsonPayload.put("text", chunkToSpeak)
jsonPayload.put("speaker", speakerId)
val jsonInputString = jsonPayload.toString()
connection.outputStream.use { os ->
val input = jsonInputString.toByteArray(Charsets.UTF_8)
os.write(input, 0, input.size)
}
val responseCode = connection.responseCode
if (responseCode != HttpURLConnection.HTTP_OK) {
val errorBody = try { connection.errorStream?.bufferedReader()?.use { it.readText() } } catch (_: Exception) { "" }
Timber.e("TTS Server request failed with code: $responseCode for URL: $serverUrl. Body: $errorBody")
return@withContext TtsAudioData(null, null, null)
}
val responseBody =
connection.inputStream.bufferedReader(Charsets.UTF_8).use { it.readText() }
val jsonResponse = JSONObject(responseBody)
if (jsonResponse.has("audio_base64") && jsonResponse.has("text_chunk")) {
val audioBase64 = jsonResponse.getString("audio_base64")
val serverTextChunk = jsonResponse.getString("text_chunk")
val audioBytes = Base64.decode(audioBase64, Base64.DEFAULT)
val wordTimings = mutableListOf<WordTimingInfo>()
if (jsonResponse.has("word_timings")) {
val timingsArray: JSONArray = jsonResponse.getJSONArray("word_timings")
for (i in 0 until timingsArray.length()) {
val timingObject = timingsArray.getJSONObject(i)
wordTimings.add(
WordTimingInfo(
word = timingObject.getString("word"),
startTime = timingObject.getDouble("startTime")
)
)
}
}
tempAudioFile = File.createTempFile(
"tts_audio_chunk_",
audioFileExtension,
applicationContext.cacheDir
)
FileOutputStream(tempAudioFile).use { output -> output.write(audioBytes) }
TtsAudioData(tempAudioFile, serverTextChunk, wordTimings)
} else {
Timber.e("DownloadAudioChunk: 'audio_base64' or 'text_chunk' field missing."
)
TtsAudioData(null, null, null)
}
} catch (e: Exception) {
Timber.e(e, "DownloadAudioChunk: TTS Request Exception: ${e.message}")
tempAudioFile?.delete()
TtsAudioData(null, null, null)
}
}
}
private val downloadAudioChunk: suspend (String, String) -> TtsAudioData =
{ chunkToSpeak, speakerId ->
downloadFromTtsServer(
chunkToSpeak,
speakerId,
googleCloudWorkerTtsUrl,
".mp3"
)
}
private val synthesizeBaseTtsChunk: suspend (String) -> TtsAudioData =
{ chunkToSpeak ->
val (file, text) = baseTtsSynthesizer.synthesizeToFile(chunkToSpeak)
TtsAudioData(file, text, null)
}
private val audioGenerator: suspend (text: String, speaker: String, mode: TtsMode) -> TtsAudioData =
{ text, speaker, mode ->
when (mode) {
TtsMode.CLOUD -> downloadAudioChunk(text, speaker)
TtsMode.BASE -> synthesizeBaseTtsChunk(text)
}
}
override fun onCreate() {
super.onCreate()
Timber.d("TtsService created.")
baseTtsSynthesizer = BaseTtsSynthesizer(this)
scope.launch {
try {
baseTtsSynthesizer.initialize()
} catch (e: Exception) {
Timber.e(e, "Base TTS synthesizer failed to initialize")
}
}
val audioAttributes = AudioAttributes.Builder()
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH)
.setUsage(C.USAGE_MEDIA)
.build()
player = ExoPlayer.Builder(this)
.setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.build()
playbackManager = TtsPlaybackManager(
player = player,
generateAudioChunk = audioGenerator
)
mediaSession = MediaSession.Builder(this, player)
.setCallback(playbackManager)
.build()
mediaSession?.let { playbackManager.setMediaSession(it) }
}
override fun onTaskRemoved(rootIntent: Intent?) {
if (!player.playWhenReady) {
stopSelf()
}
Timber.d("onTaskRemoved called, stopping service.")
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? {
return mediaSession
}
override fun onDestroy() {
Timber.d("TtsService is being destroyed.")
baseTtsSynthesizer.shutdown()
playbackManager.release()
mediaSession?.run {
player.release()
release()
mediaSession = null
}
super.onDestroy()
}
}

View file

@ -0,0 +1,176 @@
// TtsUtils.kt
package com.aryan.reader.tts
import android.content.Context
import android.media.MediaPlayer
import timber.log.Timber
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.core.net.toUri
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.net.HttpURLConnection
import java.net.URL
const val googleCloudWorkerTtsUrl = ""
const val TTS_SAMPLE_TEXT = "The greater danger for most of us lies not in setting our aim too high and falling short; but in setting our aim too low, and achieving our mark."
const val TTS_CHUNK_MAX_LENGTH = 250
const val DEFAULT_SPEAKER_ID = "en-US-Standard-F"
@Suppress("unused")
val GOOGLE_TTS_SPEAKERS = listOf(
"US Female: F" to "en-US-Standard-F",
"US Female: H" to "en-US-Standard-H",
"US Male: I" to "en-US-Standard-I",
"US Male: J" to "en-US-Standard-J"
)
fun splitTextIntoChunks(text: String, maxLengthPerChunk: Int = TTS_CHUNK_MAX_LENGTH): List<String> {
if (text.isBlank()) return emptyList()
val sentenceBoundaryRegex = Regex("""(?<!\w\.\w.)(?<![A-Z][a-z]\.)(?<=[.?!\n])\s+""")
val sentences = text.trim().split(sentenceBoundaryRegex).filter { it.isNotBlank() }
if (sentences.isEmpty()) return emptyList()
val chunks = mutableListOf<String>()
val currentChunk = StringBuilder()
for (sentence in sentences) {
if (sentence.length > maxLengthPerChunk) {
if (currentChunk.isNotEmpty()) {
chunks.add(currentChunk.toString())
currentChunk.clear()
}
chunks.add(sentence)
continue
}
if (currentChunk.isNotEmpty() && currentChunk.length + sentence.length + 1 > maxLengthPerChunk) {
chunks.add(currentChunk.toString())
currentChunk.clear()
currentChunk.append(sentence)
} else {
if (currentChunk.isNotEmpty()) {
currentChunk.append(" ")
}
currentChunk.append(sentence)
}
}
if (currentChunk.isNotEmpty()) {
chunks.add(currentChunk.toString())
}
return chunks
}
class SpeakerSamplePlayer(
private val context: Context,
private val scope: CoroutineScope
) {
private val sampleMediaPlayer = MediaPlayer()
var loadingSpeakerId by mutableStateOf<String?>(null)
var playingSpeakerId by mutableStateOf<String?>(null)
init {
sampleMediaPlayer.setOnErrorListener { mp, what, extra ->
Timber.e("MediaPlayer error: what=$what, extra=$extra. Resetting.")
playingSpeakerId = null
loadingSpeakerId = null
try {
mp.reset()
} catch (e: IllegalStateException) {
Timber.e("Error resetting MediaPlayer: ${e.message}")
}
true
}
}
@Suppress("unused")
fun playOrStop(speakerId: String) {
scope.launch {
when {
playingSpeakerId == speakerId -> {
sampleMediaPlayer.stop()
sampleMediaPlayer.reset()
playingSpeakerId = null
}
loadingSpeakerId == speakerId -> {
loadingSpeakerId = null
}
else -> playSample(speakerId)
}
}
}
private suspend fun playSample(speakerId: String) {
if (sampleMediaPlayer.isPlaying) {
sampleMediaPlayer.stop()
}
sampleMediaPlayer.reset()
loadingSpeakerId = speakerId
playingSpeakerId = null
withContext(Dispatchers.IO) {
try {
val url = URL(googleCloudWorkerTtsUrl)
val connection = url.openConnection() as HttpURLConnection
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "application/json; charset=UTF-8")
connection.setRequestProperty("Accept", "application/json")
connection.connectTimeout = 15000
connection.readTimeout = 30000
connection.doOutput = true
connection.doInput = true
val jsonPayload = JSONObject().apply {
put("text", TTS_SAMPLE_TEXT)
put("speaker", speakerId)
}
connection.outputStream.use { os ->
os.write(jsonPayload.toString().toByteArray(Charsets.UTF_8))
}
if (connection.responseCode == HttpURLConnection.HTTP_OK) {
val responseBody = connection.inputStream.bufferedReader().use { it.readText() }
val audioBase64 = JSONObject(responseBody).getString("audio_base64")
val dataUri = "data:audio/mpeg;base64,$audioBase64"
withContext(Dispatchers.Main) {
if (loadingSpeakerId != speakerId) {
return@withContext
}
sampleMediaPlayer.setDataSource(context, dataUri.toUri())
sampleMediaPlayer.setOnPreparedListener { mp ->
if (loadingSpeakerId == speakerId) {
mp.start()
playingSpeakerId = speakerId
loadingSpeakerId = null
}
}
sampleMediaPlayer.setOnCompletionListener {
if (playingSpeakerId == speakerId) playingSpeakerId = null
}
sampleMediaPlayer.prepareAsync()
}
} else {
Timber.e("Failed to fetch sample for $speakerId. Code: ${connection.responseCode}")
withContext(Dispatchers.Main) { if (loadingSpeakerId == speakerId) loadingSpeakerId = null }
}
} catch (e: Exception) {
Timber.e(e, "Exception playing sample for $speakerId: ${e.message}")
withContext(Dispatchers.Main) { if (loadingSpeakerId == speakerId) loadingSpeakerId = null }
}
}
}
fun release() {
sampleMediaPlayer.release()
}
}

Some files were not shown because too many files have changed in this diff Show more