Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
245
app/src/main/java/com/aryan/reader/AppNavigation.kt
Normal file
245
app/src/main/java/com/aryan/reader/AppNavigation.kt
Normal 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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
98
app/src/main/java/com/aryan/reader/BookImporter.kt
Normal file
98
app/src/main/java/com/aryan/reader/BookImporter.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
966
app/src/main/java/com/aryan/reader/Common.kt
Normal file
966
app/src/main/java/com/aryan/reader/Common.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
39
app/src/main/java/com/aryan/reader/FileHasher.kt
Normal file
39
app/src/main/java/com/aryan/reader/FileHasher.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
204
app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
Normal file
204
app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
254
app/src/main/java/com/aryan/reader/FontsScreen.kt
Normal file
254
app/src/main/java/com/aryan/reader/FontsScreen.kt
Normal 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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
1020
app/src/main/java/com/aryan/reader/HomeScreen.kt
Normal file
1020
app/src/main/java/com/aryan/reader/HomeScreen.kt
Normal file
File diff suppressed because it is too large
Load diff
1412
app/src/main/java/com/aryan/reader/LibraryScreen.kt
Normal file
1412
app/src/main/java/com/aryan/reader/LibraryScreen.kt
Normal file
File diff suppressed because it is too large
Load diff
91
app/src/main/java/com/aryan/reader/MainActivity.kt
Normal file
91
app/src/main/java/com/aryan/reader/MainActivity.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
94
app/src/main/java/com/aryan/reader/MainScreen.kt
Normal file
94
app/src/main/java/com/aryan/reader/MainScreen.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3114
app/src/main/java/com/aryan/reader/MainViewModel.kt
Normal file
3114
app/src/main/java/com/aryan/reader/MainViewModel.kt
Normal file
File diff suppressed because it is too large
Load diff
30
app/src/main/java/com/aryan/reader/MyApplication.kt
Normal file
30
app/src/main/java/com/aryan/reader/MyApplication.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
711
app/src/main/java/com/aryan/reader/ProScreen.kt
Normal file
711
app/src/main/java/com/aryan/reader/ProScreen.kt
Normal 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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
510
app/src/main/java/com/aryan/reader/SharedComposables.kt
Normal file
510
app/src/main/java/com/aryan/reader/SharedComposables.kt
Normal 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
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
172
app/src/main/java/com/aryan/reader/data/AppDatabase.kt
Normal file
172
app/src/main/java/com/aryan/reader/data/AppDatabase.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
app/src/main/java/com/aryan/reader/data/CustomFontDao.kt
Normal file
35
app/src/main/java/com/aryan/reader/data/CustomFontDao.kt
Normal 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?
|
||||
}
|
||||
16
app/src/main/java/com/aryan/reader/data/CustomFontEntity.kt
Normal file
16
app/src/main/java/com/aryan/reader/data/CustomFontEntity.kt
Normal 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
|
||||
)
|
||||
17
app/src/main/java/com/aryan/reader/data/FileTypeConverter.kt
Normal file
17
app/src/main/java/com/aryan/reader/data/FileTypeConverter.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
137
app/src/main/java/com/aryan/reader/data/FontsRepository.kt
Normal file
137
app/src/main/java/com/aryan/reader/data/FontsRepository.kt
Normal 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
|
||||
}
|
||||
}
|
||||
25
app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt
Normal file
25
app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt
Normal 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
|
||||
)
|
||||
59
app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
Normal file
59
app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
Normal 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)
|
||||
}
|
||||
33
app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
Normal file
33
app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
Normal 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?
|
||||
)
|
||||
126
app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
Normal file
126
app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
Normal 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
|
||||
)
|
||||
}
|
||||
201
app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
Normal file
201
app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
Normal 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
|
||||
}
|
||||
}
|
||||
35
app/src/main/java/com/aryan/reader/epub/BitmapSerializer.kt
Normal file
35
app/src/main/java/com/aryan/reader/epub/BitmapSerializer.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
31
app/src/main/java/com/aryan/reader/epub/EpubBook.kt
Normal file
31
app/src/main/java/com/aryan/reader/epub/EpubBook.kt
Normal 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
|
||||
)
|
||||
19
app/src/main/java/com/aryan/reader/epub/EpubChapter.kt
Normal file
19
app/src/main/java/com/aryan/reader/epub/EpubChapter.kt
Normal 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
|
||||
)
|
||||
36
app/src/main/java/com/aryan/reader/epub/EpubImage.kt
Normal file
36
app/src/main/java/com/aryan/reader/epub/EpubImage.kt
Normal 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
|
||||
}
|
||||
}
|
||||
662
app/src/main/java/com/aryan/reader/epub/EpubParser.kt
Normal file
662
app/src/main/java/com/aryan/reader/epub/EpubParser.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
25
app/src/main/java/com/aryan/reader/epub/EpubUtils.kt
Normal file
25
app/src/main/java/com/aryan/reader/epub/EpubUtils.kt
Normal 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
|
||||
|
||||
55
app/src/main/java/com/aryan/reader/epub/EpubXMLFileParser.kt
Normal file
55
app/src/main/java/com/aryan/reader/epub/EpubXMLFileParser.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
295
app/src/main/java/com/aryan/reader/epub/MobiParser.kt
Normal file
295
app/src/main/java/com/aryan/reader/epub/MobiParser.kt
Normal 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
|
||||
}
|
||||
}
|
||||
275
app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
Normal file
275
app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
Normal 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("<")
|
||||
'>' -> currentChapterContent.append(">")
|
||||
'&' -> currentChapterContent.append("&")
|
||||
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("\"", """)}</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()
|
||||
)
|
||||
}
|
||||
}
|
||||
965
app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
Normal file
965
app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
304
app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
Normal file
304
app/src/main/java/com/aryan/reader/epubreader/EpubReaderAi.kt
Normal 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") }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
3003
app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
Normal file
3003
app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
319
app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
Normal file
319
app/src/main/java/com/aryan/reader/epubreader/EpubReaderTts.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
689
app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt
Normal file
689
app/src/main/java/com/aryan/reader/feedback/FeedbackScreen.kt
Normal 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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
274
app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt
Normal file
274
app/src/main/java/com/aryan/reader/feedback/FeedbackViewModel.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
1196
app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
Normal file
1196
app/src/main/java/com/aryan/reader/paginatedreader/BookPaginator.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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 "• "
|
||||
}
|
||||
}
|
||||
}
|
||||
859
app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt
Normal file
859
app/src/main/java/com/aryan/reader/paginatedreader/CssParser.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
143
app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
Normal file
143
app/src/main/java/com/aryan/reader/paginatedreader/FontLoader.kt
Normal 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!! }
|
||||
}
|
||||
577
app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt
Normal file
577
app/src/main/java/com/aryan/reader/paginatedreader/HtmlParser.kt
Normal 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++)
|
||||
}
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
262
app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
Normal file
262
app/src/main/java/com/aryan/reader/paginatedreader/Locator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
1049
app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt
Normal file
1049
app/src/main/java/com/aryan/reader/paginatedreader/Paginator.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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?
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
275
app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt
Normal file
275
app/src/main/java/com/aryan/reader/pdf/AnnotationDock.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
211
app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
Normal file
211
app/src/main/java/com/aryan/reader/pdf/MagnifierComposable.kt
Normal 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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
34
app/src/main/java/com/aryan/reader/pdf/OcrModels.kt
Normal file
34
app/src/main/java/com/aryan/reader/pdf/OcrModels.kt
Normal 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?
|
||||
)
|
||||
73
app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt
Normal file
73
app/src/main/java/com/aryan/reader/pdf/PdfCoverGenerator.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
993
app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt
Normal file
993
app/src/main/java/com/aryan/reader/pdf/PdfExporter.kt
Normal 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
|
||||
}
|
||||
}
|
||||
277
app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt
Normal file
277
app/src/main/java/com/aryan/reader/pdf/PdfHelper.kt
Normal 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)
|
||||
}
|
||||
4867
app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
Normal file
4867
app/src/main/java/com/aryan/reader/pdf/PdfPageComposable.kt
Normal file
File diff suppressed because it is too large
Load diff
377
app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt
Normal file
377
app/src/main/java/com/aryan/reader/pdf/PdfTextBox.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
1828
app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
Normal file
1828
app/src/main/java/com/aryan/reader/pdf/PdfVerticalReader.kt
Normal file
File diff suppressed because it is too large
Load diff
6139
app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
Normal file
6139
app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt
Normal file
File diff suppressed because it is too large
Load diff
553
app/src/main/java/com/aryan/reader/pdf/PenIcons.kt
Normal file
553
app/src/main/java/com/aryan/reader/pdf/PenIcons.kt
Normal 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)
|
||||
}
|
||||
1385
app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt
Normal file
1385
app/src/main/java/com/aryan/reader/pdf/RichTextSystem.kt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
}
|
||||
1553
app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt
Normal file
1553
app/src/main/java/com/aryan/reader/pdf/TextAnnotationDock.kt
Normal file
File diff suppressed because it is too large
Load diff
989
app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt
Normal file
989
app/src/main/java/com/aryan/reader/pdf/ToolSettingsPopup.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
192
app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt
Normal file
192
app/src/main/java/com/aryan/reader/pdf/data/PdfAnnotationData.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
143
app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt
Normal file
143
app/src/main/java/com/aryan/reader/pdf/data/PdfTextDatabase.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
570
app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt
Normal file
570
app/src/main/java/com/aryan/reader/pdf/data/PdfTextRepository.kt
Normal 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
|
||||
}
|
||||
}
|
||||
223
app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt
Normal file
223
app/src/main/java/com/aryan/reader/tts/BaseTtsSynthesizer.kt
Normal 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")
|
||||
}
|
||||
284
app/src/main/java/com/aryan/reader/tts/TtsController.kt
Normal file
284
app/src/main/java/com/aryan/reader/tts/TtsController.kt
Normal 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
|
||||
}
|
||||
629
app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt
Normal file
629
app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt
Normal 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.")
|
||||
}
|
||||
}
|
||||
237
app/src/main/java/com/aryan/reader/tts/TtsService.kt
Normal file
237
app/src/main/java/com/aryan/reader/tts/TtsService.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
176
app/src/main/java/com/aryan/reader/tts/TtsUtils.kt
Normal file
176
app/src/main/java/com/aryan/reader/tts/TtsUtils.kt
Normal 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
Loading…
Add table
Add a link
Reference in a new issue