V1.0.46 oss (#253)

* add a UI trigger for demo annotations

* Implement internal link navigation

* Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode

* Add support for Calibre metadata

* Implement advanced library management with Room-backed collections, tags, and smart rules.

* Display book series information in book details and remove tags from home screen

* Implement zoom reset functionality in PDF viewer

* Add a "Pages" thumbnail view to the PDF navigation drawer

* Implement jump-back navigation in PDF viewer

* MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging

* Replace Gson with Kotlin Serialization in SmartCollectionEngine

* Implement ONNX-based speech bubble detection

* Implement speech bubble detection in PDF viewer

* Implement "Smart Comic Zoom" feature for PDF manga/comic reading

* Implement reader session persistence and restoration in `MainViewModel`

* Add tap-to-turn page feature to PDF viewer

* Improve book cache management and recovery

* Refactor top overlay padding logic in `PdfViewerScreen`

* Implement stylus eraser support in PDF reader

* Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls

* Introduce a customizable horizontal margin setting for the EPUB reader

* Refine folder sync and metadata handling for better conflict resolution and stability.

* Introduce user-adjustable image scaling for the EPUB reader

* Use maxWidthPx as default width fallback for blocks

* Improve cross-page text selection and header styling in the paginated reader

* Refactor speech bubble detection and UI to support segmentation masks and interactive scaling

* Improve speech bubble detection masking and rendering quality

* Update ONNX speech bubble detector to use `.ort` model and optimize inference

* Improve pagination accuracy

* Implement hierarchical folder navigation for the library.

* Refactor library item layout for improved space efficiency

* Add support for browsing and downloading Google Fonts

* Persist library landing state and add shelf search functionality

* Fix base tts sample.

* Move SpeechBubbleDetector to main source set and update ONNX dependency

* Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers.

* Refine TTS synchronization and voice management in the EPUB reader

* Adjust OCR checks for OSS flavor and optimize external dictionary intent flags

* Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail

* fix various crashes

* Refactor SpeechBubbleDetector into product flavors

* Implement speech bubble detection caching and background prefetching

* Implement on-demand download for Bubble Zoom ML model

* Add smooth animation for PDF speech bubble expansion

* fixes #252

* hide Google Fonts option, in FontsScreen, for offline variant

* Bump version to 1.0.46(46)
This commit is contained in:
Aryan 2026-04-29 16:37:53 +05:30 committed by GitHub
parent 579b6f25bf
commit d16bdd70d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 9724 additions and 1890 deletions

View file

@ -30,8 +30,8 @@ android {
applicationId = "com.aryan.reader" applicationId = "com.aryan.reader"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 45 versionCode = 46
versionName = "1.0.45" versionName = "1.0.46"
resourceConfigurations += setOf("en", "ar", "de", "tr") resourceConfigurations += setOf("en", "ar", "de", "tr")
@ -139,6 +139,17 @@ android {
version = "3.22.1" version = "3.22.1"
} }
} }
testOptions {
unitTests.isReturnDefaultValues = true
unitTests.all {
it.jvmArgs("-Xss2m")
}
}
configurations {
named("testImplementation") {
exclude(group = "org.slf4j", module = "slf4j-android")
}
}
} }
//noinspection UseTomlInstead //noinspection UseTomlInstead
dependencies { dependencies {
@ -230,12 +241,12 @@ dependencies {
implementation("com.materialkolor:material-kolor:5.0.0-alpha07") implementation("com.materialkolor:material-kolor:5.0.0-alpha07")
debugImplementation("org.tensorflow:tensorflow-lite:2.17.0")
debugImplementation("org.tensorflow:tensorflow-lite-support:0.5.0")
debugImplementation("org.tensorflow:tensorflow-lite-gpu:2.17.0")
debugImplementation("org.tensorflow:tensorflow-lite-gpu-api:2.17.0")
implementation("androidx.core:core-splashscreen:1.2.0") implementation("androidx.core:core-splashscreen:1.2.0")
testImplementation("junit:junit:4.13.2")
testImplementation("io.mockk:mockk-android:1.14.9")
testImplementation(libs.kotlinx.coroutines.test)
testImplementation("org.slf4j:slf4j-nop:2.0.17")
} }
spotless { spotless {

Binary file not shown.

View file

@ -83,3 +83,8 @@
-keep class com.aryan.reader.pdf.NativePdfiumBridge { -keep class com.aryan.reader.pdf.NativePdfiumBridge {
*; *;
} }
# Preserve ONNX Runtime Java classes
-keep class ai.onnxruntime.** { *; }
-keepnames class ai.onnxruntime.** { *; }
-keepclassmembers class ai.onnxruntime.** { *; }

View file

@ -79,7 +79,7 @@
} }
img, svg, video, canvas { img, svg, video, canvas {
max-width: 100%; width: 100%; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain; max-width: 100%; width: auto; height: auto; display: block; margin-left: auto; margin-right: auto; background-color: transparent; object-fit: contain;
} }
figure img { figure img {
@ -481,10 +481,18 @@
if (anchor) { if (anchor) {
var href = anchor.getAttribute('href'); var href = anchor.getAttribute('href');
var epubType = anchor.getAttribute('epub:type'); var epubType = anchor.getAttribute('epub:type');
var linkText = (anchor.textContent || '').trim().substring(0, 80);
console.log("LINK_NAV: [JS-CLICK] href='" + href + "', epub:type='" + epubType + "', label='" + linkText + "'");
if (window.LinkNavBridge && window.LinkNavBridge.onLinkClicked) {
window.LinkNavBridge.onLinkClicked(href || '', epubType || '', linkText);
}
console.log("FootnoteDiag: Link clicked. href: '" + href + "', epub:type: '" + epubType + "'"); console.log("FootnoteDiag: Link clicked. href: '" + href + "', epub:type: '" + epubType + "'");
if ((href && href.startsWith('#')) || epubType === 'noteref') { if ((href && href.startsWith('#')) || epubType === 'noteref') {
console.log("LINK_NAV: [JS-CLASSIFY] type=FRAGMENT_OR_FOOTNOTE, href='" + href + "'");
var targetId = href ? href.substring(1) : null; var targetId = href ? href.substring(1) : null;
console.log("FootnoteDiag: Extracted targetId: '" + targetId + "'"); console.log("FootnoteDiag: Extracted targetId: '" + targetId + "'");
@ -504,10 +512,12 @@
} }
} }
} }
} else {
console.log("LINK_NAV: [JS-NO-ANCHOR] No <a> tag found in click target hierarchy");
} }
}, true); }, true);
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap) { window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin) {
var logTag = "ReaderFontDiagnosis"; var logTag = "ReaderFontDiagnosis";
console.log( console.log(
logTag + logTag +
@ -520,7 +530,11 @@
"', Align: '" + "', Align: '" +
textAlign + textAlign +
"', Gap: " + "', Gap: " +
paragraphGap paragraphGap +
", ImageSize: " +
imageSize +
", HorizontalMargin: " +
horizontalMargin
); );
var dynamicStyleId = "dynamicReaderStyles"; var dynamicStyleId = "dynamicReaderStyles";
@ -535,10 +549,14 @@
var newFontSize = parseFloat(fontSizeEm); var newFontSize = parseFloat(fontSizeEm);
var newLineHeight = parseFloat(lineHeight); var newLineHeight = parseFloat(lineHeight);
var newGap = parseFloat(paragraphGap); var newGap = parseFloat(paragraphGap);
var newImageSize = parseFloat(imageSize);
var newHorizontalMargin = parseFloat(horizontalMargin);
if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0; if (isNaN(newFontSize) || newFontSize < 0.5 || newFontSize > 5.0) newFontSize = 1.0;
if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0; if (isNaN(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 1.0;
if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0; if (isNaN(newGap) || newGap < 0.0 || newGap > 3.0) newGap = 1.0;
if (isNaN(newImageSize) || newImageSize < 0.5 || newImageSize > 2.0) newImageSize = 1.0;
if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0;
var fontCss = ""; var fontCss = "";
if (fontFamily && fontFamily !== "Original" && fontFamily !== "") { if (fontFamily && fontFamily !== "Original" && fontFamily !== "") {
@ -590,7 +608,31 @@
`; `;
} }
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss].join("\n"); var horizontalPaddingPx = Math.max(0, 16 * newHorizontalMargin);
var horizontalMarginCss = `
body {
box-sizing: border-box !important;
padding-left: ${horizontalPaddingPx}px !important;
padding-right: ${horizontalPaddingPx}px !important;
}
`;
var imageCss = `
:root {
--reader-image-size: ${newImageSize};
}
body img,
body svg,
body video,
body canvas,
body image {
width: min(100%, calc(100% * var(--reader-image-size))) !important;
max-width: 100% !important;
height: auto !important;
}
`;
dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n");
setTimeout( setTimeout(
function () { function () {

File diff suppressed because it is too large Load diff

View file

@ -300,14 +300,18 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectCount(JNIEnv *env, jcl
extern "C" JNIEXPORT jint JNICALL extern "C" JNIEXPORT jint JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectType(JNIEnv *env, jclass clazz, jlong pagePtr, jint index) {
if (!init_pdfium() || !get_object_func || !get_object_type_func) return 0; if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || pagePtr == 0 || index < 0) return 0;
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
if (index >= object_count) return 0;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index); void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
return obj ? get_object_type_func(obj) : 0; return obj ? get_object_type_func(obj) : 0;
} }
extern "C" JNIEXPORT jboolean JNICALL extern "C" JNIEXPORT jboolean JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) { Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jfloatArray outRect) {
if (!init_pdfium() || !get_object_func || !get_object_bounds_func) return JNI_FALSE; if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_bounds_func || pagePtr == 0 || index < 0 || outRect == nullptr) return JNI_FALSE;
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
if (index >= object_count) return JNI_FALSE;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index); void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
if (!obj) return JNI_FALSE; if (!obj) return JNI_FALSE;
@ -322,7 +326,9 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *en
extern "C" JNIEXPORT jintArray JNICALL extern "C" JNIEXPORT jintArray JNICALL
Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) { Java_com_aryan_reader_pdf_NativePdfiumBridge_extractImagePixels(JNIEnv *env, jclass clazz, jlong pagePtr, jint index, jintArray dimens) {
if (!init_pdfium() || !get_object_func || !get_image_bitmap_func || !bitmap_get_buffer_func) return nullptr; if (!init_pdfium() || !count_objects_func || !get_object_func || !get_object_type_func || !get_image_bitmap_func || !bitmap_get_buffer_func || pagePtr == 0 || index < 0 || dimens == nullptr) return nullptr;
const int object_count = count_objects_func(reinterpret_cast<void*>(pagePtr));
if (index >= object_count) return nullptr;
void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index); void* obj = get_object_func(reinterpret_cast<void*>(pagePtr), index);
if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE

View file

@ -59,6 +59,16 @@ object AppDestinations {
const val FONTS_SCREEN_ROUTE = "fonts_screen_route" const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
} }
private fun NavHostController.navigateSingleTopTo(route: String) {
navigate(route) {
launchSingleTop = true
restoreState = true
popUpTo(graph.startDestinationId) {
saveState = true
}
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM) @RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable @Composable
@ -77,25 +87,21 @@ fun AppNavigation(
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> { FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
if (uiState.selectedPdfUri != null) { if (uiState.selectedPdfUri != null) {
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) { if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) { navController.navigateSingleTopTo(AppDestinations.PDF_VIEWER_ROUTE)
popUpTo(AppDestinations.MAIN_ROUTE)
}
} }
} }
} }
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> { FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
if (uiState.selectedEpubBook != null) { if (uiState.selectedEpubBook != null) {
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) { if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
navController.navigate(AppDestinations.EPUB_READER_ROUTE) { navController.navigateSingleTopTo(AppDestinations.EPUB_READER_ROUTE)
popUpTo(AppDestinations.MAIN_ROUTE)
}
} }
} }
} }
null -> { null -> {
val currentRoute = navController.currentBackStackEntry?.destination?.route val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) { if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) {
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false) navController.navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
} }
} }
} }

View file

@ -185,6 +185,7 @@ import timber.log.Timber
import java.io.File import java.io.File
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import java.util.Locale
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -1284,6 +1285,21 @@ fun DeviceVoicesTab(
.clickable(enabled = !isTtsActive && isBaseMode) { .clickable(enabled = !isTtsActive && isBaseMode) {
savedVoiceName = null savedVoiceName = null
saveNativeVoice(context, null) saveNativeVoice(context, null)
ttsEngine?.apply {
try {
val defaultLocale = Locale.getDefault()
language = defaultLocale
val fallbackVoice =
defaultVoice ?: voices.firstOrNull { voice ->
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
} ?: voices.firstOrNull { voice ->
voice.locale == defaultLocale
}
fallbackVoice?.let { voice = it }
} catch (e: Exception) {
Timber.tag("TTS_DIAGNOSE").w(e, "Failed to reset preview engine to system default voice")
}
}
} }
) { ) {
Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { Row(modifier = Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
@ -1361,7 +1377,7 @@ fun DeviceVoicesTab(
enabled = !isTtsActive, enabled = !isTtsActive,
onClick = { onClick = {
ttsEngine?.apply { ttsEngine?.apply {
language = voice.locale this.voice = voice
speak("This is a voice sample.", TextToSpeech.QUEUE_FLUSH, null, "sample_${voice.name}") speak("This is a voice sample.", TextToSpeech.QUEUE_FLUSH, null, "sample_${voice.name}")
} }
} }

View file

@ -37,7 +37,10 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.data.LocalSyncUtils import com.aryan.reader.data.LocalSyncUtils
import com.aryan.reader.data.FolderBookMetadata
import java.io.File import java.io.File
import android.provider.DocumentsContract
import java.security.MessageDigest
class FolderSyncWorker( class FolderSyncWorker(
private val appContext: Context, private val appContext: Context,
@ -138,17 +141,17 @@ class FolderSyncWorker(
LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree) LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree)
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...") Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri) val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri).toMutableMap()
Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...") Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...")
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree) val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap()
folderMetadataMap.forEach { (bookId, remoteMeta) -> folderMetadataMap.forEach { (bookId, remoteMeta) ->
val existingItem = recentFilesRepository.getFileByBookId(bookId) val existingItem = recentFilesRepository.getFileByBookId(bookId)
if (existingItem != null) { if (existingItem != null) {
if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) { if (remoteMeta.lastModifiedTimestamp > existingItem.lastModifiedTimestamp) {
Timber.tag("FolderSync").d("Applying remote update for $bookId (Progress: ${remoteMeta.progressPercentage}%)") Timber.tag("PdfPositionDebug").w("FolderSyncWorker applies remote progress for $bookId | Local Page: ${existingItem.lastPage} -> Remote Page: ${remoteMeta.lastPage}")
val itemToUpdate = existingItem.copy( val itemToUpdate = existingItem.copy(
lastChapterIndex = remoteMeta.lastChapterIndex, lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage, lastPage = remoteMeta.lastPage,
@ -164,6 +167,8 @@ class FolderSyncWorker(
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
) )
recentFilesRepository.addRecentFile(itemToUpdate) recentFilesRepository.addRecentFile(itemToUpdate)
} else {
Timber.tag("PdfPositionDebug").d("FolderSyncWorker: Local meta is newer/equal for $bookId. Ignoring remote. Local Page: ${existingItem.lastPage}")
} }
} }
} }
@ -202,39 +207,39 @@ class FolderSyncWorker(
val contentResolver = appContext.contentResolver val contentResolver = appContext.contentResolver
val foundBookIds = mutableSetOf<String>() val foundBookIds = mutableSetOf<String>()
val newOrUpdatedItems = mutableListOf<RecentFileItem>() val newOrUpdatedItems = mutableListOf<RecentFileItem>()
val existingItemsMap = existingFolderBooks.associateBy { it.bookId } val existingItemsMap = existingFolderBooks.associateBy { it.bookId }.toMutableMap()
val rootDocId = android.provider.DocumentsContract.getTreeDocumentId(folderUri) val rootDocId = DocumentsContract.getTreeDocumentId(folderUri)
val dirQueue = ArrayDeque<String>() val dirQueue = ArrayDeque<String>()
dirQueue.add(rootDocId) dirQueue.add(rootDocId)
val projection = arrayOf( val projection = arrayOf(
android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID, DocumentsContract.Document.COLUMN_DOCUMENT_ID,
android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_DISPLAY_NAME,
android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE, DocumentsContract.Document.COLUMN_MIME_TYPE,
android.provider.DocumentsContract.Document.COLUMN_SIZE, DocumentsContract.Document.COLUMN_SIZE,
android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED DocumentsContract.Document.COLUMN_LAST_MODIFIED
) )
while (dirQueue.isNotEmpty()) { while (dirQueue.isNotEmpty()) {
if (isStopped) break if (isStopped) break
val currentDocId = dirQueue.removeFirst() val currentDocId = dirQueue.removeFirst()
val childrenUri = android.provider.DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId) val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
try { try {
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor -> contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID) val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME) val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE) val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_SIZE) val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val modCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED) val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
while (cursor.moveToNext() && !isStopped) { while (cursor.moveToNext() && !isStopped) {
val docId = cursor.getString(idCol) val docId = cursor.getString(idCol)
val name = cursor.getString(nameCol) ?: "" val name = cursor.getString(nameCol) ?: ""
val mimeType = cursor.getString(mimeCol) val mimeType = cursor.getString(mimeCol)
if (mimeType == android.provider.DocumentsContract.Document.MIME_TYPE_DIR) { if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
if (!name.startsWith(".") && name != "EpistemeSyncData") { if (!name.startsWith(".") && name != "EpistemeSyncData") {
dirQueue.add(docId) dirQueue.add(docId)
} }
@ -244,29 +249,49 @@ class FolderSyncWorker(
val type = getFileType(name, mimeType) val type = getFileType(name, mimeType)
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) { if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
val stableId = "local_$name" val stableId = buildStableBookId(name, rootDocId, docId)
foundBookIds.add(stableId) foundBookIds.add(stableId)
val docUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(folderUri, docId) val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
var existingItem = existingItemsMap[stableId] var existingItem = existingItemsMap[stableId]
if (existingItem != null && existingItem.uriString != docUri.toString()) {
val collidedItem = existingItem
val collidedStableId = computeStableIdForStoredItem(collidedItem, rootDocId)
if (!collidedStableId.isNullOrBlank() && collidedStableId != stableId && collidedStableId != collidedItem.bookId) {
Timber.tag("FolderSync").i("Resolving folder ID collision for ${collidedItem.displayName}: ${collidedItem.bookId} -> $collidedStableId")
migrateFolderBookId(
folderUriString = folderUriString,
oldId = collidedItem.bookId,
newId = collidedStableId,
folderMetadataMap = folderMetadataMap,
preloadedSidecars = preloadedSidecars,
existingItemsMap = existingItemsMap
)
existingItem = existingItemsMap[stableId]
}
}
if (existingItem == null) { if (existingItem == null) {
val oldItem = existingItemsMap.values.find { it.bookId.startsWith("local_${name}_") && it.bookId != stableId } val oldItem = existingItemsMap.values.find {
it.bookId != stableId && (
it.uriString == docUri.toString() ||
it.bookId.startsWith("local_${name}_")
)
}
if (oldItem != null) { if (oldItem != null) {
val oldId = oldItem.bookId val oldId = oldItem.bookId
Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId") Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId")
recentFilesRepository.migrateBookIdLocally(oldId, stableId) migrateFolderBookId(
existingItem = recentFilesRepository.getFileByBookId(stableId) folderUriString = folderUriString,
oldId = oldId,
try { newId = stableId,
val syncDir = documentTree.findFile("EpistemeSyncData") folderMetadataMap = folderMetadataMap,
if (syncDir != null) { preloadedSidecars = preloadedSidecars,
syncDir.findFile(".$oldId.json")?.delete() existingItemsMap = existingItemsMap
syncDir.findFile("$oldId.json")?.delete() )
syncDir.findFile(".$oldId" + "_annotations.json")?.delete() existingItem = existingItemsMap[stableId]
}
} catch (_: Exception) { Timber.tag("FolderSync").e("Failed to clean up orphaned SAF sidecars.") }
} }
} }
@ -410,4 +435,78 @@ class FolderSyncWorker(
else -> null else -> null
} }
} }
private fun buildStableBookId(name: String, rootDocId: String, docId: String): String {
val relativePath = buildRelativePath(rootDocId, docId, name)
if (relativePath.equals(name, ignoreCase = true)) {
return "local_$name"
}
return "local_${name}_${shortHash(relativePath.lowercase())}"
}
private fun buildRelativePath(rootDocId: String, docId: String, fallbackName: String): String {
val rootPath = rootDocId.substringAfter(':', "")
val docPath = docId.substringAfter(':', "")
if (docPath.isBlank()) return fallbackName
val relative = if (rootPath.isNotBlank() && docPath.startsWith(rootPath)) {
docPath.removePrefix(rootPath).trimStart('/')
} else {
docPath.substringAfterLast('/', fallbackName)
}
return relative.ifBlank { fallbackName }
}
private fun shortHash(value: String): String {
val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }.take(12)
}
private fun computeStableIdForStoredItem(item: RecentFileItem, rootDocId: String): String? {
val uriString = item.uriString ?: return null
return try {
val docId = DocumentsContract.getDocumentId(uriString.toUri())
buildStableBookId(item.displayName, rootDocId, docId)
} catch (_: Exception) {
null
}
}
private suspend fun migrateFolderBookId(
folderUriString: String,
oldId: String,
newId: String,
folderMetadataMap: MutableMap<String, FolderBookMetadata>,
preloadedSidecars: MutableMap<String, Pair<Long, String>>,
existingItemsMap: MutableMap<String, RecentFileItem>
) {
if (oldId == newId) return
recentFilesRepository.migrateBookIdLocally(oldId, newId)
val oldMetadata = folderMetadataMap.remove(oldId)
if (oldMetadata != null && newId !in folderMetadataMap) {
val migratedMetadata = oldMetadata.copy(bookId = newId)
LocalSyncUtils.saveMetadataToFolder(appContext, folderUriString.toUri(), migratedMetadata)
folderMetadataMap[newId] = migratedMetadata
}
val oldSidecar = preloadedSidecars.remove(oldId)
if (oldSidecar != null && newId !in preloadedSidecars) {
LocalSyncUtils.saveAnnotationSidecar(
context = appContext,
sourceFolderUri = folderUriString.toUri(),
bookId = newId,
jsonPayload = oldSidecar.second,
timestamp = oldSidecar.first
)
preloadedSidecars[newId] = oldSidecar
}
LocalSyncUtils.deleteBookSidecars(appContext, folderUriString.toUri(), oldId)
existingItemsMap.remove(oldId)
recentFilesRepository.getFileByBookId(newId)?.let {
existingItemsMap[newId] = it
}
}
} }

View file

@ -17,9 +17,13 @@
* *
* mail: epistemereader@gmail.com * mail: epistemereader@gmail.com
*/ */
// FontsScreen.kt
@file:Suppress("KotlinConstantConditions")
package com.aryan.reader package com.aryan.reader
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -34,24 +38,32 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -59,6 +71,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@ -69,6 +83,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import java.io.File import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun FontsScreen( fun FontsScreen(
viewModel: MainViewModel, viewModel: MainViewModel,
@ -76,24 +91,21 @@ fun FontsScreen(
) { ) {
val fonts: List<CustomFontEntity> by viewModel.customFonts.collectAsStateWithLifecycle() val fonts: List<CustomFontEntity> by viewModel.customFonts.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
// Dialog state // Dialog state
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) } var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
var showGoogleFontsSheet by remember { mutableStateOf(false) }
val pickFontLauncher = rememberFilePickerLauncher { uris -> val pickFontLauncher = rememberFilePickerLauncher { uris ->
uris.firstOrNull()?.let { viewModel.importFont(it) } uris.firstOrNull()?.let { viewModel.importFont(it) }
} }
// Font mime types filter
val fontMimeTypes = arrayOf( val fontMimeTypes = arrayOf(
"font/ttf", "font/ttf", "font/otf", "font/woff2",
"font/otf", "application/x-font-ttf", "application/x-font-otf",
"font/woff2", "application/font-woff2", "application/vnd.ms-opentype",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-woff2",
"application/vnd.ms-opentype",
"application/x-font-opentype" "application/x-font-opentype"
) )
@ -111,6 +123,18 @@ fun FontsScreen(
}, },
floatingActionButton = { floatingActionButton = {
if (fonts.isNotEmpty()) { if (fonts.isNotEmpty()) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
ExtendedFloatingActionButton(
onClick = { showGoogleFontsSheet = true },
icon = { Icon(Icons.Default.CloudDownload, contentDescription = null) },
text = { Text("Google Fonts") },
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer
)
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = { pickFontLauncher.launch(fontMimeTypes) }, onClick = { pickFontLauncher.launch(fontMimeTypes) },
icon = { Icon(Icons.Default.Add, contentDescription = null) }, icon = { Icon(Icons.Default.Add, contentDescription = null) },
@ -118,6 +142,7 @@ fun FontsScreen(
) )
} }
} }
}
) { padding -> ) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) { Box(modifier = Modifier.fillMaxSize().padding(padding)) {
if (fonts.isEmpty()) { if (fonts.isEmpty()) {
@ -125,7 +150,9 @@ fun FontsScreen(
title = stringResource(R.string.no_custom_fonts), title = stringResource(R.string.no_custom_fonts),
message = stringResource(R.string.import_fonts_desc), message = stringResource(R.string.import_fonts_desc),
onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) }, onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize(),
secondaryButtonText = "Browse Google Fonts",
onSecondaryClick = { showGoogleFontsSheet = true }
) )
} else { } else {
LazyColumn( LazyColumn(
@ -155,7 +182,6 @@ fun FontsScreen(
} }
} }
} }
// Banner messages removed as requested
} }
} }
@ -173,8 +199,172 @@ fun FontsScreen(
} }
) )
} }
if (showGoogleFontsSheet) {
GoogleFontsBottomSheet(
onDismiss = { showGoogleFontsSheet = false },
existingFonts = fonts,
getFullFontList = { viewModel.loadGoogleFontsList(context) },
onDownloadFont = { fontName, onComplete ->
viewModel.downloadGoogleFont(fontName, onComplete)
}
)
}
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun GoogleFontsBottomSheet(
onDismiss: () -> Unit,
existingFonts: List<CustomFontEntity>,
getFullFontList: () -> List<String>,
onDownloadFont: (String, () -> Unit) -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var searchQuery by remember { mutableStateOf("") }
var downloadingFontName by remember { mutableStateOf<String?>(null) }
// Curated presets to show when search is empty
val popularPresets = remember {
listOf(
"Merriweather", "Open Sans", "Playfair Display", "Montserrat", "Oswald", "Raleway", "Nunito",
"Poppins", "Ubuntu", "Fira Sans", "Quicksand", "Crimson Text",
"Literata", "EB Garamond", "Libre Baskerville", "Inter", "Work Sans"
)
}
// Lazy evaluation of the full list only when typing
val displayList = remember(searchQuery) {
if (searchQuery.isBlank()) {
popularPresets
} else {
val allFonts = getFullFontList()
allFonts.filter { it.contains(searchQuery, ignoreCase = true) }.take(50) // Limit to 50 for performance
}
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Text(
text = "Browse Google Fonts",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 12.dp)
)
OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search 1900+ fonts...") },
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
singleLine = true,
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
) {
if (searchQuery.isBlank()) {
item {
Text(
text = "Popular Choices",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(vertical = 4.dp)
)
}
} else if (displayList.isEmpty()) {
item {
Text(
text = "No fonts found matching '$searchQuery'",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(16.dp)
)
}
}
items(displayList) { fontName ->
val isDownloaded = remember(existingFonts, fontName) {
existingFonts.any { it.displayName.equals(fontName, ignoreCase = true) }
}
val isDownloading = downloadingFontName == fontName
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(enabled = !isDownloaded && !isDownloading) {
downloadingFontName = fontName
onDownloadFont(fontName) {
if (downloadingFontName == fontName) {
downloadingFontName = null
}
}
}
.background(
if (isDownloaded) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.2f)
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = fontName,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (isDownloaded) FontWeight.Bold else FontWeight.Medium,
color = if (isDownloaded) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface
)
Box(modifier = Modifier.padding(start = 12.dp)) {
when {
isDownloaded -> {
Icon(
Icons.Default.Check,
contentDescription = "Already Downloaded",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
}
isDownloading -> {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.primary
)
}
else -> {
Icon(
Icons.Default.CloudDownload,
contentDescription = "Download",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp)
)
}
}
}
}
}
}
}
}
}
// Existing unchanged components
@Composable @Composable
fun FontListItem( fun FontListItem(
font: CustomFontEntity, font: CustomFontEntity,

View file

@ -339,12 +339,17 @@ fun HomeScreen(
}, },
onAppThemeClick = { showAppThemePanel = true }, onAppThemeClick = { showAppThemePanel = true },
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) }, onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
onLanguageClick = { showLanguageDialog = true } onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
onLanguageClick = { showLanguageDialog = true },
onExportLogsClick = { viewModel.exportLogsToFile(context) }
) )
} else { } else {
ContextualTopAppBar( ContextualTopAppBar(
selectedItemCount = selectedContextItems.size, selectedItemCount = selectedContextItems.size,
onNavIconClick = { viewModel.clearContextualAction() }, onNavIconClick = { viewModel.clearContextualAction() },
onTagClick = {
viewModel.openTagSelection(selectedContextItems.map { it.bookId }.toSet())
},
onInfoClick = { onInfoClick = {
if (selectedContextItems.size == 1) { if (selectedContextItems.size == 1) {
itemForInfoDialog = selectedContextItems.first() itemForInfoDialog = selectedContextItems.first()
@ -472,7 +477,8 @@ fun HomeScreen(
}, },
onUpdateName = { newName -> onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName) viewModel.updateCustomName(item.bookId, newName)
} },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
) )
} }
} }
@ -988,7 +994,9 @@ fun DefaultTopAppBar(
onStrictFilterToggleClick: () -> Unit, onStrictFilterToggleClick: () -> Unit,
onAppThemeClick: () -> Unit, onAppThemeClick: () -> Unit,
onTestPanelDetectionClick: () -> Unit, onTestPanelDetectionClick: () -> Unit,
onLanguageClick: () -> Unit onTestSpeechBubbleDetectionClick: () -> Unit,
onLanguageClick: () -> Unit,
onExportLogsClick: () -> Unit
) { ) {
var showOptionsMenu by remember { mutableStateOf(false) } var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) } var showLimitMenu by remember { mutableStateOf(false) }
@ -1094,6 +1102,16 @@ fun DefaultTopAppBar(
onTestPanelDetectionClick() onTestPanelDetectionClick()
showOptionsMenu = false showOptionsMenu = false
}) })
DropdownMenuItem(text = { Text("Test Speech Bubble ML Detection") }, onClick = {
onTestSpeechBubbleDetectionClick()
showOptionsMenu = false
})
DropdownMenuItem(text = { Text("Export Logs (Last 5000 lines)") }, onClick = {
onExportLogsClick()
showOptionsMenu = false
})
} }
if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") { if (BuildConfig.DEBUG && BuildConfig.FLAVOR != "oss") {

View file

@ -64,6 +64,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.automirrored.filled.List
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
@ -76,6 +78,7 @@ import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
@ -131,6 +134,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.opds.OpdsAcquisition import com.aryan.reader.opds.OpdsAcquisition
import com.aryan.reader.opds.OpdsCatalog import com.aryan.reader.opds.OpdsCatalog
import com.aryan.reader.opds.OpdsEntry import com.aryan.reader.opds.OpdsEntry
@ -159,7 +163,7 @@ fun LibraryScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems val selectedItems = uiState.contextualActionItems
val isContextualModeActive = selectedItems.isNotEmpty() val isContextualModeActive = selectedItems.isNotEmpty()
val selectedShelves = uiState.contextualActionShelfNames val selectedShelves = uiState.contextualActionShelfIds
val isShelfContextualModeActive = selectedShelves.isNotEmpty() val isShelfContextualModeActive = selectedShelves.isNotEmpty()
val sortOrder = uiState.sortOrder val sortOrder = uiState.sortOrder
val shelves = uiState.shelves val shelves = uiState.shelves
@ -278,6 +282,7 @@ fun LibraryScreen(
selectedShelves = selectedShelves, selectedShelves = selectedShelves,
sortOrder = sortOrder, sortOrder = sortOrder,
libraryFilters = uiState.libraryFilters, libraryFilters = uiState.libraryFilters,
allTags = uiState.allTags,
pinnedLibraryBookIds = uiState.pinnedLibraryBookIds, pinnedLibraryBookIds = uiState.pinnedLibraryBookIds,
pagerState = pagerState, pagerState = pagerState,
scope = scope, scope = scope,
@ -289,6 +294,7 @@ fun LibraryScreen(
onFilterClick = { showFilterSheet = true }, onFilterClick = { showFilterSheet = true },
onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) }, onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) },
onRemoveFilter = { viewModel.updateLibraryFilters(it) }, onRemoveFilter = { viewModel.updateLibraryFilters(it) },
onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) },
onPinClick = { viewModel.togglePinForContextualItems(isHome = false) }, onPinClick = { viewModel.togglePinForContextualItems(isHome = false) },
onClearSelection = { viewModel.clearContextualAction() }, onClearSelection = { viewModel.clearContextualAction() },
onItemClick = viewModel::onRecentFileClicked, onItemClick = viewModel::onRecentFileClicked,
@ -358,6 +364,7 @@ fun LibraryScreen(
if (showFilterSheet) { if (showFilterSheet) {
LibraryFilterSheet( LibraryFilterSheet(
filters = uiState.libraryFilters, filters = uiState.libraryFilters,
allTags = uiState.allTags,
syncedFolders = uiState.syncedFolders, syncedFolders = uiState.syncedFolders,
onApply = { viewModel.updateLibraryFilters(it) }, onApply = { viewModel.updateLibraryFilters(it) },
onDismiss = { showFilterSheet = false } onDismiss = { showFilterSheet = false }
@ -385,7 +392,8 @@ fun LibraryScreen(
}, },
onUpdateName = { newName -> onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName) viewModel.updateCustomName(item.bookId, newName)
} },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
) )
} }
} }
@ -399,7 +407,7 @@ fun ShelfScreen(
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems val selectedItems = uiState.contextualActionItems
val viewingShelfName = uiState.viewingShelfName val viewingShelfId = uiState.viewingShelfId
val isAddingBooks = uiState.isAddingBooksToShelf val isAddingBooks = uiState.isAddingBooksToShelf
val shelves = uiState.shelves val shelves = uiState.shelves
val sortOrder = uiState.sortOrder val sortOrder = uiState.sortOrder
@ -414,17 +422,20 @@ fun ShelfScreen(
when { when {
selectedItems.isNotEmpty() -> viewModel.clearContextualAction() selectedItems.isNotEmpty() -> viewModel.clearContextualAction()
isAddingBooks -> viewModel.dismissAddBooksToShelf() isAddingBooks -> viewModel.dismissAddBooksToShelf()
else -> viewModel.unselectShelf() else -> viewModel.navigateBackFromShelf()
} }
} }
val currentShelf = shelves.find { it.name == viewingShelfName } val currentShelf = shelves.find { it.id == viewingShelfId }
val childShelves = remember(shelves, currentShelf) {
currentShelf?.childShelfIds?.mapNotNull { childId -> shelves.find { it.id == childId } } ?: emptyList()
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
if (viewingShelfName != null && currentShelf != null) { if (viewingShelfId != null && currentShelf != null) {
if (isAddingBooks) { if (isAddingBooks) {
AddBooksModeScreen( AddBooksModeScreen(
shelfName = viewingShelfName, shelfName = currentShelf.name,
availableBooks = uiState.booksAvailableForAdding, availableBooks = uiState.booksAvailableForAdding,
selectedBookUris = uiState.booksSelectedForAdding, selectedBookUris = uiState.booksSelectedForAdding,
currentSource = uiState.addBooksSource, currentSource = uiState.addBooksSource,
@ -433,20 +444,23 @@ fun ShelfScreen(
onSourceChange = viewModel::setAddBooksSource, onSourceChange = viewModel::setAddBooksSource,
onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) }, onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) },
onBack = viewModel::dismissAddBooksToShelf, onBack = viewModel::dismissAddBooksToShelf,
onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfName) }, onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) },
downloadingBookIds = uiState.downloadingBookIds downloadingBookIds = uiState.downloadingBookIds
) )
} else { } else {
ShelfDetailScreen( ShelfDetailScreen(
shelf = currentShelf, shelf = currentShelf,
childShelves = childShelves,
selectedItems = selectedItems, selectedItems = selectedItems,
sortOrder = sortOrder, sortOrder = sortOrder,
onSortOrderChange = viewModel::setSortOrder, onSortOrderChange = viewModel::setSortOrder,
onBack = viewModel::unselectShelf, onBack = viewModel::navigateBackFromShelf,
onAddBooksClick = viewModel::showAddBooksToShelf, onAddBooksClick = viewModel::showAddBooksToShelf,
onChildShelfClick = viewModel::onShelfClick,
onBookClick = viewModel::onRecentFileClicked, onBookClick = viewModel::onRecentFileClicked,
onBookLongClick = viewModel::onRecentItemLongPress, onBookLongClick = viewModel::onRecentItemLongPress,
onClearSelection = viewModel::clearContextualAction, onClearSelection = viewModel::clearContextualAction,
onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) },
onInfoClick = { onInfoClick = {
if (selectedItems.size == 1) { if (selectedItems.size == 1) {
itemForInfoDialog = selectedItems.first() itemForInfoDialog = selectedItems.first()
@ -454,24 +468,27 @@ fun ShelfScreen(
} }
}, },
onDeleteClick = { showRemoveFromShelfDialog = true }, onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.name) }, onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.name) }, onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
downloadingBookIds = uiState.downloadingBookIds downloadingBookIds = uiState.downloadingBookIds
) )
} }
} }
if (showRenameDialogFor != null) { if (showRenameDialogFor != null) {
val shelfToRename = shelves.find { it.id == showRenameDialogFor }
if (shelfToRename != null) {
RenameShelfDialog( RenameShelfDialog(
initialName = showRenameDialogFor, initialName = shelfToRename.name,
onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) }, onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) },
onDismiss = viewModel::dismissRenameShelfDialog onDismiss = viewModel::dismissRenameShelfDialog
) )
} }
}
if (showDeleteDialogFor != null) { if (showDeleteDialogFor != null) {
DeleteShelfConfirmationDialog( DeleteShelfConfirmationDialog(
shelfName = showDeleteDialogFor, shelfName = shelves.find { it.id == showDeleteDialogFor }?.name ?: "",
onConfirm = { viewModel.deleteShelf(showDeleteDialogFor) }, onConfirm = { viewModel.deleteShelf(showDeleteDialogFor) },
onDismiss = viewModel::dismissDeleteShelfDialog onDismiss = viewModel::dismissDeleteShelfDialog
) )
@ -493,13 +510,9 @@ fun ShelfScreen(
if (showInfoDialog) { if (showInfoDialog) {
FileInfoDialog( FileInfoDialog(
item = item, item = item,
onDismiss = { onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
showInfoDialog = false onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) },
itemForInfoDialog = null onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
}
) )
} }
} }
@ -519,6 +532,7 @@ fun LibraryScreenContent(
selectedShelves: Set<String>, selectedShelves: Set<String>,
sortOrder: SortOrder, sortOrder: SortOrder,
libraryFilters: LibraryFilters, libraryFilters: LibraryFilters,
allTags: List<TagEntity>,
pinnedLibraryBookIds: Set<String>, pinnedLibraryBookIds: Set<String>,
pagerState: PagerState, pagerState: PagerState,
scope: CoroutineScope, scope: CoroutineScope,
@ -530,6 +544,7 @@ fun LibraryScreenContent(
onFilterClick: () -> Unit, onFilterClick: () -> Unit,
onClearFilters: () -> Unit, onClearFilters: () -> Unit,
onRemoveFilter: (LibraryFilters) -> Unit, onRemoveFilter: (LibraryFilters) -> Unit,
onTagClick: () -> Unit,
onPinClick: () -> Unit, onPinClick: () -> Unit,
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onItemClick: (RecentFileItem) -> Unit, onItemClick: (RecentFileItem) -> Unit,
@ -590,6 +605,7 @@ fun LibraryScreenContent(
ContextualTopAppBar( ContextualTopAppBar(
selectedItemCount = selectedItems.size, selectedItemCount = selectedItems.size,
onNavIconClick = onClearSelection, onNavIconClick = onClearSelection,
onTagClick = onTagClick,
onPinClick = onPinClick, onPinClick = onPinClick,
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onDeleteClick = onDeleteClick, onDeleteClick = onDeleteClick,
@ -734,6 +750,19 @@ fun LibraryScreenContent(
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) } trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
) )
} }
if (libraryFilters.tagIds.isNotEmpty()) {
val selectedTags = allTags.filter { it.id in libraryFilters.tagIds }
val tagLabel = when {
selectedTags.isEmpty() -> "${libraryFilters.tagIds.size} tags"
selectedTags.size <= 2 -> selectedTags.joinToString { it.name }
else -> "${selectedTags.size} tags"
}
AssistChip(
onClick = { onRemoveFilter(libraryFilters.copy(tagIds = emptySet())) },
label = { Text("Tags: $tagLabel") },
trailingIcon = { Icon(Icons.Default.Close, contentDescription = "Clear", modifier = Modifier.size(16.dp)) }
)
}
} }
} }
} }
@ -846,16 +875,60 @@ private fun ShelvesScreen(
onShelfLongClick: (Shelf) -> Unit, onShelfLongClick: (Shelf) -> Unit,
selectedShelves: Set<String>, selectedShelves: Set<String>,
) { ) {
val tagShelves = remember(shelves) { shelves.filter { it.type == ShelfType.TAG && it.bookCount > 0 } }
val visibleShelves = remember(shelves) {
shelves.filter { shelf ->
when {
shelf.type == ShelfType.TAG -> false
shelf.type == ShelfType.FOLDER -> shelf.parentShelfId == null
else -> true
}
}
}
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp), contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(shelves, key = { it.name }) { shelf -> if (tagShelves.isNotEmpty() && selectedShelves.isEmpty()) {
item {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
text = "Browse by tag",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
tagShelves.forEach { shelf ->
FilterChip(
selected = false,
onClick = { onShelfClick(shelf) },
label = { Text(shelf.name) },
leadingIcon = {
Icon(
painter = painterResource(id = R.drawable.tag),
contentDescription = null,
modifier = Modifier.size(16.dp)
)
}
)
}
}
}
}
}
items(visibleShelves, key = { it.id }) { shelf ->
ShelfListItem( ShelfListItem(
shelf = shelf, shelf = shelf,
isSelected = shelf.name in selectedShelves, isSelected = shelf.id in selectedShelves,
onItemClick = { onShelfClick(shelf) }, onItemClick = { onShelfClick(shelf) },
onItemLongClick = { onShelfLongClick(shelf) } onItemLongClick = { onShelfLongClick(shelf) }
) )
@ -904,14 +977,17 @@ private fun CreateShelfDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit
@Composable @Composable
private fun ShelfDetailScreen( private fun ShelfDetailScreen(
shelf: Shelf, shelf: Shelf,
childShelves: List<Shelf>,
selectedItems: Set<RecentFileItem>, selectedItems: Set<RecentFileItem>,
sortOrder: SortOrder, sortOrder: SortOrder,
onSortOrderChange: (SortOrder) -> Unit, onSortOrderChange: (SortOrder) -> Unit,
onBack: () -> Unit, onBack: () -> Unit,
onAddBooksClick: () -> Unit, onAddBooksClick: () -> Unit,
onChildShelfClick: (Shelf) -> Unit,
onBookClick: (RecentFileItem) -> Unit, onBookClick: (RecentFileItem) -> Unit,
onBookLongClick: (RecentFileItem) -> Unit, onBookLongClick: (RecentFileItem) -> Unit,
onClearSelection: () -> Unit, onClearSelection: () -> Unit,
onTagClick: () -> Unit,
onInfoClick: () -> Unit, onInfoClick: () -> Unit,
onDeleteClick: () -> Unit, onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit, onRenameShelf: () -> Unit,
@ -919,8 +995,71 @@ private fun ShelfDetailScreen(
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
) { ) {
val isContextualModeActive = selectedItems.isNotEmpty() val isContextualModeActive = selectedItems.isNotEmpty()
val isFolderShelf = shelf.type == ShelfType.FOLDER
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
var showMoreMenu by remember { mutableStateOf(false) } var showMoreMenu by remember { mutableStateOf(false) }
var isSearchActive by remember(shelf.id) { mutableStateOf(false) }
var searchQuery by remember(shelf.id) { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
var searchFieldValue by remember(isSearchActive, shelf.id) {
mutableStateOf(TextFieldValue(searchQuery, TextRange(searchQuery.length)))
}
val normalizedQuery = searchQuery.trim()
val filteredChildShelves = remember(childShelves, normalizedQuery) {
if (normalizedQuery.isBlank()) {
childShelves
} else {
childShelves.filter { childShelf ->
childShelf.name.contains(normalizedQuery, ignoreCase = true) ||
childShelf.books.any { item ->
item.displayName.contains(normalizedQuery, ignoreCase = true) ||
item.title?.contains(normalizedQuery, ignoreCase = true) == true ||
item.author?.contains(normalizedQuery, ignoreCase = true) == true
}
}
}
}
val filteredDirectBooks = remember(shelf.directBooks, normalizedQuery) {
if (normalizedQuery.isBlank()) {
shelf.directBooks
} else {
shelf.directBooks.filter { item ->
item.displayName.contains(normalizedQuery, ignoreCase = true) ||
item.title?.contains(normalizedQuery, ignoreCase = true) == true ||
item.author?.contains(normalizedQuery, ignoreCase = true) == true ||
item.tags.any { tag -> tag.name.contains(normalizedQuery, ignoreCase = true) }
}
}
}
LaunchedEffect(searchQuery) {
if (searchFieldValue.text != searchQuery) {
searchFieldValue = searchFieldValue.copy(
text = searchQuery,
selection = TextRange(searchQuery.length)
)
}
}
LaunchedEffect(isSearchActive) {
if (isSearchActive) {
searchFocusRequester.requestFocus()
}
}
fun clearShelfSearchQuery() {
searchQuery = ""
searchFieldValue = TextFieldValue("", TextRange.Zero)
}
fun closeShelfSearch() {
isSearchActive = false
clearShelfSearchQuery()
}
BackHandler(enabled = isSearchActive) {
closeShelfSearch()
}
Scaffold( Scaffold(
modifier = Modifier, modifier = Modifier,
@ -929,9 +1068,54 @@ private fun ShelfDetailScreen(
ContextualTopAppBar( ContextualTopAppBar(
selectedItemCount = selectedItems.size, selectedItemCount = selectedItems.size,
onNavIconClick = onClearSelection, onNavIconClick = onClearSelection,
onTagClick = onTagClick,
onInfoClick = onInfoClick, onInfoClick = onInfoClick,
onDeleteClick = onDeleteClick onDeleteClick = onDeleteClick
) )
} else if (isSearchActive) {
Surface(
shadowElevation = 4.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.height(64.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { closeShelfSearch() }) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search")
}
OutlinedTextField(
value = searchFieldValue,
onValueChange = {
searchFieldValue = it
searchQuery = it.text
},
placeholder = { Text(stringResource(R.string.search_placeholder)) },
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
.focusRequester(searchFocusRequester),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
),
trailingIcon = {
if (searchQuery.isNotEmpty()) {
IconButton(onClick = { clearShelfSearchQuery() }) {
Icon(Icons.Default.Close, contentDescription = "Clear query")
}
}
}
)
}
}
} else { } else {
CustomTopAppBar( CustomTopAppBar(
title = { title = {
@ -942,7 +1126,14 @@ private fun ShelfDetailScreen(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Text( Text(
text = getBookCountString(shelf.bookCount), text = when {
isFolderShelf && shelf.childShelfCount > 0 && shelf.directBookCount > 0 ->
"${shelf.childShelfCount} folders • ${getBookCountString(shelf.directBookCount)}"
isFolderShelf && shelf.childShelfCount > 0 ->
"${shelf.childShelfCount} folders"
isFolderShelf -> getBookCountString(shelf.directBookCount)
else -> getBookCountString(shelf.bookCount)
},
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@ -988,7 +1179,14 @@ private fun ShelfDetailScreen(
} }
} }
if (shelf.name != "Unshelved") { IconButton(onClick = { isSearchActive = true }) {
Icon(
imageVector = Icons.Default.Search,
contentDescription = "Search shelf"
)
}
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved") {
Box { Box {
IconButton(onClick = { showMoreMenu = true }) { IconButton(onClick = { showMoreMenu = true }) {
Icon( Icon(
@ -1022,21 +1220,27 @@ private fun ShelfDetailScreen(
} }
}, },
floatingActionButton = { floatingActionButton = {
if (shelf.name != "Unshelved" && !isContextualModeActive) { if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved" && !isContextualModeActive) {
ExtendedFloatingActionButton( ExtendedFloatingActionButton(
onClick = onAddBooksClick, onClick = onAddBooksClick,
icon = { Icon(Icons.Default.Add, contentDescription = null) }, icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text(stringResource(R.string.fab_add_books)) } text = { Text(stringResource(R.string.fab_add_books)) }
) )
} }
} },
) { paddingValues -> content = { paddingValues ->
if (shelf.books.isEmpty()) { if (filteredChildShelves.isEmpty() && filteredDirectBooks.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize().padding(paddingValues), modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text(stringResource(R.string.shelf_empty), style = MaterialTheme.typography.bodyLarge) Text(
text = if (normalizedQuery.isBlank()) stringResource(R.string.shelf_empty) else stringResource(
R.string.no_results_found,
normalizedQuery
),
style = MaterialTheme.typography.bodyLarge
)
} }
} else { } else {
LazyColumn( LazyColumn(
@ -1044,7 +1248,41 @@ private fun ShelfDetailScreen(
contentPadding = PaddingValues(16.dp), contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items(shelf.books, key = { it.bookId }) { item -> if (filteredChildShelves.isNotEmpty()) {
if (isFolderShelf) {
item {
Text(
text = "Folders",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
items(filteredChildShelves, key = { it.id }) { childShelf ->
ShelfListItem(
shelf = childShelf,
isSelected = false,
onItemClick = { onChildShelfClick(childShelf) },
onItemLongClick = {},
showHierarchyIndent = false
)
}
}
if (filteredDirectBooks.isNotEmpty() && isFolderShelf && filteredChildShelves.isNotEmpty()) {
item {
Spacer(modifier = Modifier.height(4.dp))
}
item {
Text(
text = "Files",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
items(filteredDirectBooks, key = { it.bookId }) { item ->
LibraryListItem( LibraryListItem(
item = item, item = item,
isSelected = selectedItems.any { it.bookId == item.bookId }, isSelected = selectedItems.any { it.bookId == item.bookId },
@ -1056,6 +1294,7 @@ private fun ShelfDetailScreen(
} }
} }
} }
)
} }
@Composable @Composable
@ -1073,11 +1312,11 @@ private fun AddBooksModeScreen(
downloadingBookIds: Set<String>, downloadingBookIds: Set<String>,
) { ) {
var showSortMenu by remember { mutableStateOf(false) } var showSortMenu by remember { mutableStateOf(false) }
var showSourceMenu by remember { mutableStateOf(false) }
Scaffold( Scaffold(
modifier = Modifier, modifier = Modifier,
topBar = { topBar = {
Column {
CustomTopAppBar( CustomTopAppBar(
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) }, title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
navigationIcon = { navigationIcon = {
@ -1116,33 +1355,24 @@ private fun AddBooksModeScreen(
} }
} }
} }
Box {
IconButton(onClick = { showSourceMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More options")
} }
DropdownMenu( )
expanded = showSourceMenu, Row(
onDismissRequest = { showSourceMenu = false } modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
AddBooksSource.entries.forEach { source -> AddBooksSource.entries.forEach { source ->
DropdownMenuItem( FilterChip(
text = { Text(source.displayName) }, selected = source == currentSource,
onClick = { onClick = { onSourceChange(source) },
onSourceChange(source) label = { Text(source.displayName) }
showSourceMenu = false
},
trailingIcon = {
if (source == currentSource) {
Icon(Icons.Default.Check, contentDescription = "Selected")
}
}
) )
} }
} }
} }
}
)
}, },
floatingActionButton = { floatingActionButton = {
if (selectedBookUris.isNotEmpty()) { if (selectedBookUris.isNotEmpty()) {
@ -1152,15 +1382,19 @@ private fun AddBooksModeScreen(
onClick = onAddSelectedBooks onClick = onAddSelectedBooks
) )
} }
} },
) { paddingValues -> content = { paddingValues ->
if (availableBooks.isEmpty()) { if (availableBooks.isEmpty()) {
Box( Box(
modifier = Modifier.fillMaxSize().padding(paddingValues), modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
text = if (currentSource == AddBooksSource.UNSHELVED) stringResource(R.string.no_unshelved_books) else stringResource(R.string.all_books_in_shelf), text = if (currentSource == AddBooksSource.UNSHELVED) {
stringResource(R.string.no_unshelved_books)
} else {
stringResource(R.string.all_books_in_shelf)
},
style = MaterialTheme.typography.bodyLarge style = MaterialTheme.typography.bodyLarge
) )
} }
@ -1183,6 +1417,7 @@ private fun AddBooksModeScreen(
} }
} }
} }
)
} }
@Composable @Composable
@ -1260,7 +1495,10 @@ private fun ShelfListItem(
isSelected: Boolean, isSelected: Boolean,
onItemClick: () -> Unit, onItemClick: () -> Unit,
onItemLongClick: () -> Unit, onItemLongClick: () -> Unit,
showHierarchyIndent: Boolean = true,
) { ) {
val folderIndent = if (showHierarchyIndent && shelf.type == ShelfType.FOLDER) (shelf.depth * 14).dp else 0.dp
androidx.compose.material3.ElevatedCard( androidx.compose.material3.ElevatedCard(
shape = MaterialTheme.shapes.large, shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors( colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
@ -1286,7 +1524,7 @@ private fun ShelfListItem(
) )
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), modifier = Modifier.padding(start = 12.dp + folderIndent, end = 12.dp, top = 8.dp, bottom = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
ShelfCover(shelf = shelf) ShelfCover(shelf = shelf)
@ -1294,6 +1532,21 @@ private fun ShelfListItem(
Spacer(modifier = Modifier.width(16.dp)) Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val icon = when (shelf.type) {
ShelfType.SMART -> Icons.Default.Star
ShelfType.TAG -> Icons.AutoMirrored.Filled.LibraryBooks
ShelfType.FOLDER -> Icons.Default.Folder
ShelfType.SERIES -> Icons.AutoMirrored.Filled.LibraryBooks
ShelfType.MANUAL -> Icons.AutoMirrored.Filled.List
}
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = shelf.name, text = shelf.name,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
@ -1301,6 +1554,7 @@ private fun ShelfListItem(
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
}
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = getBookCountString(shelf.bookCount), text = getBookCountString(shelf.bookCount),
@ -1378,7 +1632,6 @@ private fun LibraryListItem(
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) )
if (isSelected) { if (isSelected) {
Box( Box(
modifier = Modifier.matchParentSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), modifier = Modifier.matchParentSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
@ -1432,16 +1685,20 @@ private fun LibraryListItem(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
FileTypeBadge(type = item.type, overlay = false) FileTypeBadge(type = item.type, overlay = false)
Box( if (item.tags.isNotEmpty()) {
modifier = Modifier BookTagChipsRow(
.weight(1f) tags = item.tags,
.height(28.dp), compact = true,
contentAlignment = Alignment.CenterStart modifier = Modifier.weight(1f, fill = false)
) { )
} else {
Spacer(modifier = Modifier.weight(1f))
}
if (!item.isAvailable) { if (!item.isAvailable) {
Surface( Surface(
shape = RoundedCornerShape(50), shape = RoundedCornerShape(50),
@ -1486,7 +1743,6 @@ private fun LibraryListItem(
} }
} }
} }
}
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
@ -1558,7 +1814,7 @@ private fun DeleteShelfConfirmationDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_delete_shelf)) }, title = { Text(stringResource(R.string.dialog_delete_shelf)) },
text = { Text(stringResource(R.string.dialog_delete_shelf_desc)) }, text = { Text(stringResource(R.string.dialog_delete_shelf_desc, shelfName)) },
confirmButton = { confirmButton = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) } TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
}, },
@ -1912,6 +2168,7 @@ private fun EditFolderFiltersDialog(
@Composable @Composable
fun LibraryFilterSheet( fun LibraryFilterSheet(
filters: LibraryFilters, filters: LibraryFilters,
allTags: List<TagEntity>,
syncedFolders: List<SyncedFolder>, syncedFolders: List<SyncedFolder>,
onApply: (LibraryFilters) -> Unit, onApply: (LibraryFilters) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
@ -1987,6 +2244,41 @@ fun LibraryFilterSheet(
} }
} }
if (allTags.isNotEmpty()) {
Text("Tags", style = MaterialTheme.typography.titleMedium)
FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
allTags.forEach { tag ->
val selected = tag.id in currentFilters.tagIds
FilterChip(
selected = selected,
onClick = {
val newSet = if (selected) {
currentFilters.tagIds - tag.id
} else {
currentFilters.tagIds + tag.id
}
currentFilters = currentFilters.copy(tagIds = newSet)
},
label = { Text(tag.name) },
leadingIcon = {
Box(
modifier = Modifier
.size(10.dp)
.background(
Color(tag.color ?: 0xFF64B5F6.toInt()),
CircleShape
)
)
}
)
}
}
}
Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) { Row(modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) {
TextButton(onClick = { currentFilters = LibraryFilters() }) { TextButton(onClick = { currentFilters = LibraryFilters() }) {
Text(stringResource(R.string.clear_all)) Text(stringResource(R.string.clear_all))

View file

@ -69,8 +69,9 @@ fun MainScreen(
} }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val viewingShelfName = uiState.viewingShelfName val viewingShelfName = uiState.viewingShelfId
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
if (viewingShelfName != null) { if (viewingShelfName != null) {
ShelfScreen(viewModel = viewModel) ShelfScreen(viewModel = viewModel)
} else { } else {
@ -125,4 +126,20 @@ fun MainScreen(
} }
} }
} }
if (uiState.showTagSelectionDialogFor.isNotEmpty()) {
TagSelectionBottomSheet(
allTags = uiState.allTags,
selectedBookIds = uiState.showTagSelectionDialogFor,
booksWithTags = uiState.rawLibraryFiles,
onCreateAndAssign = { name ->
viewModel.createAndAssignTag(name, uiState.showTagSelectionDialogFor)
},
onToggleTag = { tagId, assign ->
viewModel.toggleTagForBooks(tagId, uiState.showTagSelectionDialogFor, assign)
},
onDismiss = viewModel::closeTagSelection
)
}
}
} }

File diff suppressed because it is too large Load diff

View file

@ -7,6 +7,7 @@ import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFilesRepository import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.ImportedFileCache
import com.aryan.reader.epub.MobiParser import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator import com.aryan.reader.pdf.PdfCoverGenerator
import io.legere.pdfiumandroid.PdfiumCore import io.legere.pdfiumandroid.PdfiumCore
@ -55,6 +56,13 @@ class MetadataExtractionWorker(
if (item.sourceFolderUri == null) return@forEach if (item.sourceFolderUri == null) return@forEach
val tempExtractionDir =
if (item.type == FileType.EPUB || item.type == FileType.MOBI || item.type == FileType.ODT || item.type == FileType.FODT) {
ImportedFileCache.createTemporaryBookDir(appContext, item.bookId, "metadata")
} else {
null
}
try { try {
val uri = item.uriString?.toUri() ?: return@forEach val uri = item.uriString?.toUri() ?: return@forEach
val type = item.type val type = item.type
@ -86,7 +94,8 @@ class MetadataExtractionWorker(
inputStream = inputStream, inputStream = inputStream,
bookId = item.bookId, bookId = item.bookId,
originalBookNameHint = item.displayName, originalBookNameHint = item.displayName,
parseContent = false parseContent = false,
extractionDirOverride = tempExtractionDir
) )
title = book.title.takeIf { it.isNotBlank() && it != "content" } title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
@ -97,7 +106,8 @@ class MetadataExtractionWorker(
inputStream = inputStream, inputStream = inputStream,
bookId = item.bookId, bookId = item.bookId,
originalBookNameHint = item.displayName, originalBookNameHint = item.displayName,
parseContent = false parseContent = false,
extractionDirOverride = tempExtractionDir
) )
book?.let { book?.let {
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" } title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
@ -139,7 +149,8 @@ class MetadataExtractionWorker(
bookId = item.bookId, bookId = item.bookId,
originalBookNameHint = item.displayName, originalBookNameHint = item.displayName,
isFlat = type == FileType.FODT, isFlat = type == FileType.FODT,
parseContent = false parseContent = false,
extractionDirOverride = tempExtractionDir
) )
title = book.title.takeIf { it.isNotBlank() && it != "content" } title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) } author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
@ -166,13 +177,16 @@ class MetadataExtractionWorker(
Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}") Timber.tag("MetadataWorker").e(e, "Failed to extract metadata for ${item.displayName}")
} finally { } finally {
try { try {
val cacheDir = File(appContext.cacheDir, "imported_file_${item.bookId}") if (tempExtractionDir?.exists() == true) {
if (cacheDir.exists()) { val deleted = tempExtractionDir.deleteRecursively()
val deleted = cacheDir.deleteRecursively() if (deleted) {
if (deleted) Timber.tag("MetadataWorker").d("Cleaned up extraction cache for ${item.bookId}") Timber.tag("MetadataWorker")
.d("Cleaned up temporary extraction cache for ${item.bookId}")
}
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("MetadataWorker").e(e, "Failed to clean up extraction cache for ${item.bookId}") Timber.tag("MetadataWorker")
.e(e, "Failed to clean up temporary extraction cache for ${item.bookId}")
} }
} }
} }

View file

@ -33,7 +33,19 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.ui.state.ToggleableState
import androidx.compose.material3.TriStateCheckbox
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import com.aryan.reader.data.TagEntity
import androidx.compose.material.icons.filled.Search
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -90,7 +102,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
@ -220,6 +231,7 @@ fun ContextualTopAppBar(
selectedItemCount: Int, selectedItemCount: Int,
onNavIconClick: () -> Unit, onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null, onInfoClick: (() -> Unit)? = null,
onTagClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null, onSelectAllClick: (() -> Unit)? = null,
onPinClick: (() -> Unit)? = null, onPinClick: (() -> Unit)? = null,
onDeleteClick: () -> Unit onDeleteClick: () -> Unit
@ -232,6 +244,11 @@ fun ContextualTopAppBar(
} }
}, },
actions = { actions = {
if (onTagClick != null) {
IconButton(onClick = onTagClick) {
Icon(painterResource(id = R.drawable.tag), contentDescription = "Tag")
}
}
if (onPinClick != null) { if (onPinClick != null) {
IconButton(onClick = onPinClick) { IconButton(onClick = onPinClick) {
Icon(Icons.Filled.PushPin, contentDescription = stringResource(R.string.pin_unpin)) Icon(Icons.Filled.PushPin, contentDescription = stringResource(R.string.pin_unpin))
@ -342,7 +359,7 @@ fun DeleteConfirmationDialog(
} }
@Composable @Composable
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit) { fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit, onOpenTags: () -> Unit) {
LocalContext.current LocalContext.current
@Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current @Suppress("DEPRECATION") val clipboardManager = LocalClipboardManager.current
@ -465,6 +482,14 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let { item.author?.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }?.let {
InfoRowDetailed(stringResource(R.string.author), it) InfoRowDetailed(stringResource(R.string.author), it)
} }
item.seriesName?.takeIf { it.isNotBlank() }?.let { series ->
val seriesText = if (item.seriesIndex != null && item.seriesIndex > 0) {
"$series #${item.seriesIndex.toInt()}"
} else {
series
}
InfoRowDetailed("Series", seriesText)
}
InfoRowDetailed(stringResource(R.string.format), item.type.name) InfoRowDetailed(stringResource(R.string.format), item.type.name)
InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize)) InfoRowDetailed(stringResource(R.string.size), formatFileSize(item.fileSize))
InfoRowDetailed(stringResource(R.string.added), formattedDate) InfoRowDetailed(stringResource(R.string.added), formattedDate)
@ -488,6 +513,19 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
) )
} }
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically) {
Text("Tags", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
TextButton(onClick = onOpenTags) { Text("+ Add / Edit") }
}
if (item.tags.isNotEmpty()) {
BookTagChipsRow(tags = item.tags, compact = false)
} else {
Text("No tags assigned.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -901,6 +939,53 @@ fun FileTypeBadge(type: FileType, modifier: Modifier = Modifier, overlay: Boolea
} }
} }
private fun TagEntity.displayColor(): Color = Color(color ?: 0xFF64B5F6.toInt())
@Composable
fun BookTagChipsRow(
tags: List<TagEntity>,
modifier: Modifier = Modifier,
compact: Boolean = true,
) {
if (tags.isEmpty()) return
Row(
modifier = modifier.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(if (compact) 6.dp else 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
tags.forEach { tag ->
val tagColor = tag.displayColor()
Surface(
shape = RoundedCornerShape(50),
color = tagColor.copy(alpha = 0.14f),
contentColor = tagColor
) {
Row(
modifier = Modifier.padding(
horizontal = if (compact) 8.dp else 10.dp,
vertical = if (compact) 4.dp else 6.dp
),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Box(
modifier = Modifier
.size(if (compact) 6.dp else 8.dp)
.background(tagColor, androidx.compose.foundation.shape.CircleShape)
)
Text(
text = tag.name,
style = if (compact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
maxLines = 1
)
}
}
}
}
}
private const val UNKNOWN_AUTHOR_LABEL = "No author listed" private const val UNKNOWN_AUTHOR_LABEL = "No author listed"
fun RecentFileItem.cardTitle(): String { fun RecentFileItem.cardTitle(): String {
@ -1047,3 +1132,91 @@ fun ReadingProgressSection(
) )
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TagSelectionBottomSheet(
allTags: List<TagEntity>,
selectedBookIds: Set<String>,
booksWithTags: List<RecentFileItem>,
onCreateAndAssign: (String) -> Unit,
onToggleTag: (String, Boolean) -> Unit,
onDismiss: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var searchQuery by remember { mutableStateOf("") }
val filteredTags = remember(allTags, searchQuery) {
if (searchQuery.isBlank()) allTags else allTags.filter { it.name.contains(searchQuery, ignoreCase = true) }
}
val exactMatch = allTags.any { it.name.equals(searchQuery.trim(), ignoreCase = true) }
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
Column(modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp).heightIn(max = 500.dp)) {
Text("Apply Tags", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 16.dp))
androidx.compose.material3.OutlinedTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier.fillMaxWidth(),
placeholder = { Text("Search or create tag...") },
singleLine = true,
shape = RoundedCornerShape(16.dp),
leadingIcon = { Icon(Icons.Default.Search, null) }
)
Spacer(modifier = Modifier.height(16.dp))
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f, fill = false)) {
if (searchQuery.isNotBlank() && !exactMatch) {
item {
Row(
modifier = Modifier.fillMaxWidth().clickable {
onCreateAndAssign(searchQuery)
searchQuery = ""
}.padding(vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Add, null, tint = MaterialTheme.colorScheme.primary)
Spacer(modifier = Modifier.width(16.dp))
Text("Create \"${searchQuery.trim()}\"", color = MaterialTheme.colorScheme.primary)
}
}
}
items(filteredTags, key = { it.id }) { tag ->
var checkedCount = 0
selectedBookIds.forEach { bookId ->
val book = booksWithTags.find { it.bookId == bookId }
if (book?.tags?.any { it.id == tag.id } == true) checkedCount++
}
val state = when (checkedCount) {
0 -> ToggleableState.Off
selectedBookIds.size -> ToggleableState.On
else -> ToggleableState.Indeterminate
}
Row(
modifier = Modifier.fillMaxWidth().clickable {
val assign = state != ToggleableState.On
onToggleTag(tag.id, assign)
}.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
TriStateCheckbox(state = state, onClick = null)
Spacer(modifier = Modifier.width(16.dp))
Surface(shape = androidx.compose.foundation.shape.CircleShape, color = Color(tag.color ?: 0xFF64B5F6.toInt()).copy(alpha = 0.2f), modifier = Modifier.size(24.dp)) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Icon(painterResource(id = R.drawable.tag), contentDescription = null, modifier = Modifier.size(12.dp), tint = Color(tag.color ?: 0xFF64B5F6.toInt()))
}
}
Spacer(modifier = Modifier.width(12.dp))
Text(tag.name, style = MaterialTheme.typography.bodyLarge)
}
}
}
}
}
}

View file

@ -27,11 +27,24 @@ import androidx.room.TypeConverters
import androidx.room.migration.Migration import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities =[RecentFileEntity::class, CustomFontEntity::class], version = 16, exportSchema = false) @Database(
entities =[
RecentFileEntity::class,
CustomFontEntity::class,
ShelfEntity::class,
BookShelfCrossRef::class,
TagEntity::class,
BookTagCrossRef::class
],
version = 18,
exportSchema = false
)
@TypeConverters(FileTypeConverter::class) @TypeConverters(FileTypeConverter::class)
abstract class AppDatabase : RoomDatabase() { abstract class AppDatabase : RoomDatabase() {
abstract fun recentFileDao(): RecentFileDao abstract fun recentFileDao(): RecentFileDao
abstract fun customFontDao(): CustomFontDao abstract fun customFontDao(): CustomFontDao
abstract fun shelfDao(): ShelfDao
abstract fun tagDao(): TagDao
companion object { companion object {
@Volatile @Volatile
@ -191,6 +204,53 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
val MIGRATION_16_17 = object : Migration(16, 17) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN seriesName TEXT DEFAULT NULL")
db.execSQL("ALTER TABLE recent_files ADD COLUMN seriesIndex REAL DEFAULT NULL")
db.execSQL("ALTER TABLE recent_files ADD COLUMN description TEXT DEFAULT NULL")
}
}
val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS `shelves` (
`id` TEXT NOT NULL, `name` TEXT NOT NULL, `isSmart` INTEGER NOT NULL,
`smartRulesJson` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL,
`isDeleted` INTEGER NOT NULL, PRIMARY KEY(`id`)
)
""")
db.execSQL("""
CREATE TABLE IF NOT EXISTS `tags` (
`id` TEXT NOT NULL, `name` TEXT NOT NULL, `color` INTEGER,
`createdAt` INTEGER NOT NULL, PRIMARY KEY(`id`)
)
""")
db.execSQL("""
CREATE TABLE IF NOT EXISTS `book_shelf_cross_ref` (
`bookId` TEXT NOT NULL, `shelfId` TEXT NOT NULL, `addedAt` INTEGER NOT NULL,
PRIMARY KEY(`bookId`, `shelfId`),
FOREIGN KEY(`bookId`) REFERENCES `recent_files`(`bookId`) ON UPDATE NO ACTION ON DELETE CASCADE,
FOREIGN KEY(`shelfId`) REFERENCES `shelves`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
)
""")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_shelf_cross_ref_shelfId` ON `book_shelf_cross_ref` (`shelfId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_shelf_cross_ref_bookId` ON `book_shelf_cross_ref` (`bookId`)")
db.execSQL("""
CREATE TABLE IF NOT EXISTS `book_tag_cross_ref` (
`bookId` TEXT NOT NULL, `tagId` TEXT NOT NULL,
PRIMARY KEY(`bookId`, `tagId`),
FOREIGN KEY(`bookId`) REFERENCES `recent_files`(`bookId`) ON UPDATE NO ACTION ON DELETE CASCADE,
FOREIGN KEY(`tagId`) REFERENCES `tags`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE
)
""")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_tag_cross_ref_tagId` ON `book_tag_cross_ref` (`tagId`)")
db.execSQL("CREATE INDEX IF NOT EXISTS `index_book_tag_cross_ref_bookId` ON `book_tag_cross_ref` (`bookId`)")
}
}
fun getDatabase(context: Context): AppDatabase { fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) { return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder( val instance = Room.databaseBuilder(
@ -202,7 +262,8 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, 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_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16 MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
MIGRATION_16_17, MIGRATION_17_18
) )
.fallbackToDestructiveMigration(false) .fallbackToDestructiveMigration(false)
.build() .build()

View file

@ -0,0 +1,61 @@
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 ShelfDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertShelf(shelf: ShelfEntity)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertBookShelfCrossRefs(crossRefs: List<BookShelfCrossRef>)
@Query("SELECT * FROM shelves WHERE isDeleted = 0 ORDER BY name ASC")
fun getAllActiveShelves(): Flow<List<ShelfEntity>>
@Query("SELECT * FROM book_shelf_cross_ref")
fun getAllBookShelfCrossRefs(): Flow<List<BookShelfCrossRef>>
@Query("DELETE FROM book_shelf_cross_ref WHERE shelfId = :shelfId AND bookId IN (:bookIds)")
suspend fun removeBooksFromShelf(shelfId: String, bookIds: List<String>)
@Query("UPDATE shelves SET isDeleted = 1, updatedAt = :timestamp WHERE id = :shelfId")
suspend fun markShelfAsDeleted(shelfId: String, timestamp: Long)
@Query("UPDATE shelves SET name = :newName, updatedAt = :timestamp WHERE id = :shelfId")
suspend fun updateShelfName(shelfId: String, newName: String, timestamp: Long)
@Query("SELECT * FROM shelves WHERE id = :shelfId")
suspend fun getShelfById(shelfId: String): ShelfEntity?
@Query("SELECT * FROM book_shelf_cross_ref WHERE shelfId = :shelfId")
suspend fun getCrossRefsForShelf(shelfId: String): List<BookShelfCrossRef>
}
@Dao
interface TagDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTag(tag: TagEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTags(tags: List<TagEntity>)
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insertBookTagCrossRef(crossRef: BookTagCrossRef)
@Query("SELECT * FROM tags ORDER BY name ASC")
fun getAllTags(): Flow<List<TagEntity>>
@Query("SELECT * FROM book_tag_cross_ref")
fun getAllBookTagCrossRefs(): Flow<List<BookTagCrossRef>>
@Query("SELECT COUNT(*) FROM tags")
suspend fun getTagCount(): Int
@Query("DELETE FROM book_tag_cross_ref WHERE tagId = :tagId AND bookId = :bookId")
suspend fun removeTagFromBook(tagId: String, bookId: String)
}

View file

@ -0,0 +1,74 @@
package com.aryan.reader.data
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
@Entity(tableName = "shelves")
data class ShelfEntity(
@PrimaryKey val id: String,
val name: String,
val isSmart: Boolean = false,
val smartRulesJson: String? = null,
val createdAt: Long,
val updatedAt: Long,
val isDeleted: Boolean = false
)
@Entity(
tableName = "book_shelf_cross_ref",
primaryKeys =["bookId", "shelfId"],
foreignKeys =[
ForeignKey(
entity = RecentFileEntity::class,
parentColumns = ["bookId"],
childColumns = ["bookId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = ShelfEntity::class,
parentColumns = ["id"],
childColumns = ["shelfId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [Index(value = ["shelfId"]), Index(value = ["bookId"])]
)
data class BookShelfCrossRef(
val bookId: String,
val shelfId: String,
val addedAt: Long
)
@Entity(tableName = "tags")
data class TagEntity(
@PrimaryKey val id: String,
val name: String,
val color: Int? = null,
val createdAt: Long
)
@Entity(
tableName = "book_tag_cross_ref",
primaryKeys = ["bookId", "tagId"],
foreignKeys =[
ForeignKey(
entity = RecentFileEntity::class,
parentColumns = ["bookId"],
childColumns = ["bookId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = TagEntity::class,
parentColumns = ["id"],
childColumns =["tagId"],
onDelete = ForeignKey.CASCADE
)
],
indices =[Index(value = ["tagId"]), Index(value = ["bookId"])]
)
data class BookTagCrossRef(
val bookId: String,
val tagId: String
)

View file

@ -72,34 +72,14 @@ object LocalSyncUtils {
val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext val syncDir = getOrCreateSyncDir(rootTree) ?: return@withContext
val syncFileName = ".${metadata.bookId}.json" val syncFileName = ".${metadata.bookId}.json"
val legacyVisibleName = "${metadata.bookId}.json" val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId)
if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
val existingHidden = syncDir.findFile(syncFileName)
val existingVisible = syncDir.findFile(legacyVisibleName)
val fileToCheck = existingHidden ?: existingVisible
if (fileToCheck != null && fileToCheck.exists()) {
try {
val existingContent = context.contentResolver.openInputStream(fileToCheck.uri)?.use { input ->
input.bufferedReader().use { it.readText() }
}
if (existingContent != null) {
val existingMeta = FolderBookMetadata.fromJsonString(existingContent)
if (existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.") Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.")
return@withContext return@withContext
} }
}
} catch (_: Exception) {}
}
if (existingVisible != null && existingVisible.exists()) {
try { existingVisible.delete() } catch (_: Exception) {}
}
val tempFileName = ".${metadata.bookId}.tmp" val tempFileName = ".${metadata.bookId}.tmp"
syncDir.findFile(tempFileName)?.delete() syncDir.findFile(tempFileName)?.delete()
val tempFile = syncDir.createFile("application/json", tempFileName) val tempFile = syncDir.createFile("application/json", tempFileName)
if (tempFile == null) { if (tempFile == null) {
Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}") Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}")
@ -128,7 +108,7 @@ object LocalSyncUtils {
} }
@Suppress("KotlinConstantConditions") if (writeSuccess) { @Suppress("KotlinConstantConditions") if (writeSuccess) {
val targetFile = rootTree.findFile(syncFileName) val targetFile = syncDir.findFile(syncFileName)
if (targetFile != null && targetFile.exists()) { if (targetFile != null && targetFile.exists()) {
targetFile.delete() targetFile.delete()
} }
@ -169,8 +149,8 @@ object LocalSyncUtils {
val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId) val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json" val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json"
val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp" val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp"
syncDir.findFile(tempName)?.delete()
val tempFile = syncDir.createFile("application/json", tempName) val tempFile = syncDir.createFile("application/json", tempName)
val existingMain = syncDir.findFile(targetName)
if (currentBest != null) { if (currentBest != null) {
val (remoteTs, _) = currentBest val (remoteTs, _) = currentBest
@ -186,8 +166,6 @@ object LocalSyncUtils {
wrapper.put("data", JSONObject(jsonPayload)) wrapper.put("data", JSONObject(jsonPayload))
val contentBytes = wrapper.toString().toByteArray() val contentBytes = wrapper.toString().toByteArray()
syncDir.findFile(tempName)?.delete()
if (tempFile == null) { if (tempFile == null) {
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.") Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
return@withContext return@withContext
@ -210,7 +188,7 @@ object LocalSyncUtils {
} }
@Suppress("KotlinConstantConditions") if (writeSuccess) { @Suppress("KotlinConstantConditions") if (writeSuccess) {
val existingMain = rootTree.findFile(targetName) val existingMain = syncDir.findFile(targetName)
if (existingMain != null) { if (existingMain != null) {
if (!existingMain.delete()) { if (!existingMain.delete()) {
Timber.tag("FolderAnnotationSync").w("Failed to delete existing sidecar before rename. Attempting rename anyway (might fail on some SAF providers).") Timber.tag("FolderAnnotationSync").w("Failed to delete existing sidecar before rename. Attempting rename anyway (might fail on some SAF providers).")
@ -238,55 +216,14 @@ object LocalSyncUtils {
try { try {
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
if (syncDir == null || !syncDir.isDirectory) return@withContext results if (syncDir == null || !syncDir.isDirectory) return@withContext results
val allFiles = syncDir.listFiles() val bookIds = syncDir.listFiles()
.mapNotNull { extractAnnotationBookId(it.name) }
.toSet()
val annotationFiles = allFiles.filter { file -> for (bookId in bookIds) {
val name = file.name ?: "" val best = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
name.contains(ANNOTATION_SUFFIX) && name.endsWith(".json") && !name.endsWith(".tmp") if (best != null) {
} results[bookId] = best
val filesByBookId = annotationFiles.groupBy { file ->
val name = file.name ?: ""
var temp = name.substringBeforeLast(".json")
if (temp.contains(".sync-conflict")) {
temp = temp.substringBefore(".sync-conflict")
}
if (temp.endsWith(ANNOTATION_SUFFIX)) {
temp = temp.substring(0, temp.length - ANNOTATION_SUFFIX.length)
}
if (temp.startsWith(".")) {
temp = temp.substring(1)
}
temp
}
filesByBookId.forEach { (bookId, files) ->
if (bookId.isNotBlank()) {
var bestTs = -1L
var bestData: String? = null
for (file in files) {
try {
val content = context.contentResolver.openInputStream(file.uri)?.use {
it.bufferedReader().readText()
} ?: continue
val json = JSONObject(content)
val ts = json.optLong("timestamp", 0L)
val data = json.optJSONObject("data")?.toString()
if (data != null && ts > bestTs) {
bestTs = ts
bestData = data
}
} catch (e: Exception) {
Timber.tag("FolderAnnotationSync").e(e, "Error parsing preloaded file: ${file.name}")
}
}
if (bestData != null) {
results[bookId] = Pair(bestTs, bestData)
}
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
@ -319,12 +256,13 @@ object LocalSyncUtils {
bookId: String bookId: String
): Pair<Long, String>? { ): Pair<Long, String>? {
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}" val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
val allFiles = syncDir.listFiles() val allFiles = syncDir.listFiles()
val candidates = allFiles.filter { file -> val candidates = allFiles.filter { file ->
val name = file.name ?: "" val name = file.name ?: ""
name.startsWith(basePattern) && (name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
name.endsWith(".json") && name.endsWith(".json") &&
!name.endsWith(".tmp") && !name.endsWith(".tmp") &&
!name.contains(".syncthing.") !name.contains(".syncthing.")
@ -460,16 +398,76 @@ object LocalSyncUtils {
} }
} }
// 3. Migrate Legacy to Hidden if needed val correctName = ".${bookId}.json"
val winnerName = bestFile.name ?: "" if (bestFile.name != correctName) {
if (!winnerName.startsWith(".")) { Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName")
Timber.tag(TAG).i("Migrating legacy file to hidden: $winnerName") bestFile.renameTo(correctName)
} }
} }
return bestMeta return bestMeta
} }
private fun resolveAndCleanMetadataConflicts(
context: Context,
syncDir: DocumentFile,
bookId: String
): FolderBookMetadata? {
val candidates = syncDir.listFiles().filter { file ->
val name = file.name ?: ""
val normalizedName = if (name.startsWith(".")) name.substring(1) else name
normalizedName == "$bookId.json" ||
normalizedName.startsWith("$bookId.sync-conflict") ||
normalizedName.startsWith("$bookId.json.sync-conflict")
}
if (candidates.isEmpty()) return null
return resolveAndCleanConflicts(context, candidates, bookId)
}
private fun extractAnnotationBookId(name: String?): String? {
if (name.isNullOrBlank()) return null
var temp = name
if (!temp.contains(ANNOTATION_SUFFIX) || !temp.endsWith(".json") || temp.endsWith(".tmp")) return null
if (temp.contains(".sync-conflict")) {
temp = temp.substringBefore(".sync-conflict")
}
temp = temp.substringBeforeLast(".json")
if (temp.endsWith(ANNOTATION_SUFFIX)) {
temp = temp.substring(0, temp.length - ANNOTATION_SUFFIX.length)
}
if (temp.startsWith(".")) {
temp = temp.substring(1)
}
return temp.ifBlank { null }
}
suspend fun deleteBookSidecars(
context: Context,
sourceFolderUri: Uri,
bookId: String
) = withContext(Dispatchers.IO) {
try {
val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext
val targets = syncDir.listFiles().filter { file ->
val name = file.name ?: return@filter false
val normalized = if (name.startsWith(".")) name.substring(1) else name
normalized == "$bookId.json" ||
normalized.startsWith("$bookId.sync-conflict") ||
normalized.startsWith("$bookId.json.sync-conflict") ||
normalized.startsWith("$bookId${ANNOTATION_SUFFIX}")
}
targets.forEach {
try {
it.delete()
} catch (_: Exception) {
}
}
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Failed to delete folder sidecars for $bookId")
}
}
suspend fun getAllFolderMetadata( suspend fun getAllFolderMetadata(
context: Context, context: Context,
sourceFolderUri: Uri sourceFolderUri: Uri
@ -486,6 +484,7 @@ object LocalSyncUtils {
.filter { .filter {
val name = it.name ?: "" val name = it.name ?: ""
(name.endsWith(".json") || name.contains(".sync-conflict")) && (name.endsWith(".json") || name.contains(".sync-conflict")) &&
!name.contains(ANNOTATION_SUFFIX) &&
!name.endsWith(".tmp") && !name.endsWith(".tmp") &&
!name.contains(".syncthing.") !name.contains(".syncthing.")
} }

View file

@ -21,20 +21,19 @@
package com.aryan.reader.data package com.aryan.reader.data
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@Dao @Dao
interface RecentFileDao { interface RecentFileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertOrUpdateFile(file: RecentFileEntity) suspend fun insertOrUpdateFile(file: RecentFileEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE) @Upsert
suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>) suspend fun insertOrUpdateFiles(files: List<RecentFileEntity>)
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC") @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileSummary>> fun getRecentFiles(): Flow<List<RecentFileSummary>>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0") @Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
@ -46,7 +45,7 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId") @Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean) suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
@Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit") @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, seriesName, seriesIndex, description FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List<RecentFileSummary> fun getRecentFilesList(limit: Int): List<RecentFileSummary>
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)") @Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")

View file

@ -51,7 +51,10 @@ data class RecentFileEntity(
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean, @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
@ColumnInfo(defaultValue = "NULL") val customName: String?, @ColumnInfo(defaultValue = "NULL") val customName: String?,
@ColumnInfo(defaultValue = "NULL") val highlights: String?, @ColumnInfo(defaultValue = "NULL") val highlights: String?,
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
@ColumnInfo(defaultValue = "NULL") val description: String?
) )
data class RecentFileSummary( data class RecentFileSummary(
@ -76,5 +79,8 @@ data class RecentFileSummary(
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?, @ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?,
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean, @ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
@ColumnInfo(defaultValue = "NULL") val customName: String?, @ColumnInfo(defaultValue = "NULL") val customName: String?,
@ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long @ColumnInfo(name = "fileSize", defaultValue = "0") val fileSize: Long,
@ColumnInfo(defaultValue = "NULL") val seriesName: String?,
@ColumnInfo(defaultValue = "NULL") val seriesIndex: Double?,
@ColumnInfo(defaultValue = "NULL") val description: String?
) )

View file

@ -47,7 +47,11 @@ data class RecentFileItem(
val isReflowPreferred: Boolean = false, val isReflowPreferred: Boolean = false,
val customName: String? = null, val customName: String? = null,
val highlightsJson: String? = null, val highlightsJson: String? = null,
val fileSize: Long = 0L val fileSize: Long = 0L,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val description: String? = null,
val tags: List<TagEntity> = emptyList()
) { ) {
fun getUri(): Uri? = uriString?.toUri() fun getUri(): Uri? = uriString?.toUri()
} }
@ -77,7 +81,10 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
isReflowPreferred = this.isReflowPreferred, isReflowPreferred = this.isReflowPreferred,
customName = this.customName, customName = this.customName,
highlightsJson = this.highlights, highlightsJson = this.highlights,
fileSize = this.fileSize fileSize = this.fileSize,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
description = this.description
) )
} }
@ -106,7 +113,10 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
isReflowPreferred = this.isReflowPreferred, isReflowPreferred = this.isReflowPreferred,
customName = this.customName, customName = this.customName,
highlights = this.highlightsJson, highlights = this.highlightsJson,
fileSize = this.fileSize fileSize = this.fileSize,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
description = this.description
) )
} }
@ -184,6 +194,9 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
isReflowPreferred = this.isReflowPreferred, isReflowPreferred = this.isReflowPreferred,
customName = this.customName, customName = this.customName,
highlightsJson = null, highlightsJson = null,
fileSize = this.fileSize fileSize = this.fileSize,
seriesName = this.seriesName,
seriesIndex = this.seriesIndex,
description = this.description
) )
} }

View file

@ -28,6 +28,7 @@ import timber.log.Timber
import com.aryan.reader.BookImporter import com.aryan.reader.BookImporter
import com.aryan.reader.paginatedreader.Locator import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.pdf.PdfRichTextRepository import com.aryan.reader.pdf.PdfRichTextRepository
import com.aryan.reader.epub.ImportedFileCache
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@ -39,6 +40,8 @@ import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfTextBoxRepository import com.aryan.reader.pdf.data.PdfTextBoxRepository
import org.json.JSONObject import org.json.JSONObject
import org.json.JSONArray import org.json.JSONArray
import java.util.UUID
import androidx.core.content.edit
private const val COVER_CACHE_DIR = "cover_cache" private const val COVER_CACHE_DIR = "cover_cache"
@ -54,6 +57,11 @@ class RecentFilesRepository(private val context: Context) {
private val pdfTextBoxRepository = PdfTextBoxRepository(context) private val pdfTextBoxRepository = PdfTextBoxRepository(context)
private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context) private val pdfHighlightRepository = com.aryan.reader.pdf.data.PdfHighlightRepository(context)
val activeShelvesFlow = AppDatabase.getDatabase(context).shelfDao().getAllActiveShelves()
val shelfCrossRefsFlow = AppDatabase.getDatabase(context).shelfDao().getAllBookShelfCrossRefs()
val tagsFlow = AppDatabase.getDatabase(context).tagDao().getAllTags()
val tagCrossRefsFlow = AppDatabase.getDatabase(context).tagDao().getAllBookTagCrossRefs()
init { init {
if (!coverCacheDir.exists()) { if (!coverCacheDir.exists()) {
coverCacheDir.mkdirs() coverCacheDir.mkdirs()
@ -74,6 +82,28 @@ class RecentFilesRepository(private val context: Context) {
return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem() return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem()
} }
suspend fun addShelf(shelf: ShelfEntity) = withContext(Dispatchers.IO) {
AppDatabase.getDatabase(context).shelfDao().insertShelf(shelf)
}
suspend fun addBooksToShelf(shelfId: String, bookIds: List<String>) = withContext(Dispatchers.IO) {
val timestamp = System.currentTimeMillis()
val crossRefs = bookIds.map { BookShelfCrossRef(it, shelfId, timestamp) }
AppDatabase.getDatabase(context).shelfDao().insertBookShelfCrossRefs(crossRefs)
}
suspend fun renameShelf(shelfId: String, newName: String) = withContext(Dispatchers.IO) {
AppDatabase.getDatabase(context).shelfDao().updateShelfName(shelfId, newName, System.currentTimeMillis())
}
suspend fun deleteShelf(shelfId: String) = withContext(Dispatchers.IO) {
AppDatabase.getDatabase(context).shelfDao().markShelfAsDeleted(shelfId, System.currentTimeMillis())
}
suspend fun removeBooksFromShelf(shelfId: String, bookIds: List<String>) = withContext(Dispatchers.IO) {
AppDatabase.getDatabase(context).shelfDao().removeBooksFromShelf(shelfId, bookIds)
}
suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileItem> = withContext(Dispatchers.IO) { suspend fun getFilesBySourceFolder(sourceFolderUri: String): List<RecentFileItem> = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getFilesBySourceFolder(sourceFolderUri).map { it.toRecentFileItem() } return@withContext recentFileDao.getFilesBySourceFolder(sourceFolderUri).map { it.toRecentFileItem() }
} }
@ -82,6 +112,26 @@ class RecentFilesRepository(private val context: Context) {
return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() } return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() }
} }
suspend fun createTag(tag: TagEntity) = withContext(Dispatchers.IO) {
AppDatabase.getDatabase(context).tagDao().insertTag(tag)
}
suspend fun seedTagsIfEmpty(tags: List<TagEntity>) = withContext(Dispatchers.IO) {
if (tags.isEmpty()) return@withContext
val tagDao = AppDatabase.getDatabase(context).tagDao()
if (tagDao.getTagCount() == 0) {
tagDao.insertTags(tags)
}
}
suspend fun assignTagToBook(bookId: String, tagId: String) = withContext(Dispatchers.IO) {
AppDatabase.getDatabase(context).tagDao().insertBookTagCrossRef(BookTagCrossRef(bookId, tagId))
}
suspend fun removeTagFromBook(bookId: String, tagId: String) = withContext(Dispatchers.IO) {
AppDatabase.getDatabase(context).tagDao().removeTagFromBook(tagId, bookId)
}
suspend fun clearAllLocalData() = withContext(Dispatchers.IO) { suspend fun clearAllLocalData() = withContext(Dispatchers.IO) {
recentFileDao.clearAll() recentFileDao.clearAll()
if (coverCacheDir.exists()) { if (coverCacheDir.exists()) {
@ -131,7 +181,10 @@ class RecentFilesRepository(private val context: Context) {
isDeleted = item.isDeleted, isDeleted = item.isDeleted,
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri, sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
highlights = item.highlightsJson ?: existingItem.highlights, highlights = item.highlightsJson ?: existingItem.highlights,
fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize fileSize = if (item.fileSize > 0) item.fileSize else existingItem.fileSize,
seriesName = item.seriesName ?: existingItem.seriesName,
seriesIndex = item.seriesIndex ?: existingItem.seriesIndex,
description = item.description ?: existingItem.description
) )
} else { } else {
item.toRecentFileEntity() item.toRecentFileEntity()
@ -348,7 +401,9 @@ class RecentFilesRepository(private val context: Context) {
if (item != null) { if (item != null) {
val currentTime = System.currentTimeMillis() val currentTime = System.currentTimeMillis()
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime) recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
Timber.d("Updated PDF reading position for ${item.bookId} to page $page, progress $progress%") Timber.tag("PdfPositionDebug").i("Repository: Executed DB update for ${item.bookId} to Page $page, Progress $progress% at TS: $currentTime")
} else {
Timber.tag("PdfPositionDebug").e("Repository: DB Update Failed! No recent file found matching URI: $uriString")
} }
} }
@ -400,8 +455,7 @@ class RecentFilesRepository(private val context: Context) {
pdfTextBoxRepository.getFileForSync(item.bookId).delete() pdfTextBoxRepository.getFileForSync(item.bookId).delete()
pdfHighlightRepository.getFileForSync(item.bookId).delete() pdfHighlightRepository.getFileForSync(item.bookId).delete()
val cacheDir = File(context.cacheDir, "imported_file_${item.bookId}") ImportedFileCache.clearBookCache(context, item.bookId)
if (cacheDir.exists()) cacheDir.deleteRecursively()
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error during deep cleanup of sidecars for ${item.bookId}: ${e.message}") Timber.e(e, "Error during deep cleanup of sidecars for ${item.bookId}: ${e.message}")
} }
@ -473,8 +527,10 @@ class RecentFilesRepository(private val context: Context) {
renameSafely(pdfTextBoxRepository.getFileForSync(oldId), pdfTextBoxRepository.getFileForSync(newId)) renameSafely(pdfTextBoxRepository.getFileForSync(oldId), pdfTextBoxRepository.getFileForSync(newId))
renameSafely(pdfHighlightRepository.getFileForSync(oldId), pdfHighlightRepository.getFileForSync(newId)) renameSafely(pdfHighlightRepository.getFileForSync(oldId), pdfHighlightRepository.getFileForSync(newId))
val oldCache = File(context.cacheDir, "imported_file_$oldId") ImportedFileCache.clearTemporaryBookDirs(context, oldId)
val newCache = File(context.cacheDir, "imported_file_$newId") ImportedFileCache.clearTemporaryBookDirs(context, newId)
val oldCache = ImportedFileCache.activeBookDir(context, oldId)
val newCache = ImportedFileCache.activeBookDir(context, newId)
if (oldCache.exists()) { if (oldCache.exists()) {
if (newCache.exists()) newCache.deleteRecursively() if (newCache.exists()) newCache.deleteRecursively()
oldCache.renameTo(newCache) oldCache.renameTo(newCache)
@ -486,8 +542,7 @@ class RecentFilesRepository(private val context: Context) {
try { try {
pdfRichTextRepository.getFileForSync(bookId).delete() pdfRichTextRepository.getFileForSync(bookId).delete()
pageLayoutRepository.getLayoutFile(bookId).delete() pageLayoutRepository.getLayoutFile(bookId).delete()
val cacheDir = File(context.cacheDir, "imported_file_$bookId") ImportedFileCache.clearBookCache(context, bookId)
if (cacheDir.exists()) cacheDir.deleteRecursively()
Timber.d("Cleared layout and text caches for modified book: $bookId") Timber.d("Cleared layout and text caches for modified book: $bookId")
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "Error clearing caches for $bookId") Timber.e(e, "Error clearing caches for $bookId")
@ -502,4 +557,46 @@ class RecentFilesRepository(private val context: Context) {
} }
Timber.d("Batch inserted/updated ${items.size} recent files in DB.") Timber.d("Batch inserted/updated ${items.size} recent files in DB.")
} }
suspend fun migrateLegacyShelvesToRoom() = withContext(Dispatchers.IO) {
val prefs = context.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
if (prefs.getBoolean("is_shelves_migrated_to_room", false)) return@withContext
Timber.i("Starting migration of legacy SharedPreferences shelves to Room DB...")
val shelfNames = prefs.getStringSet("shelf_names", emptySet()) ?: emptySet()
if (shelfNames.isEmpty()) {
prefs.edit { putBoolean("is_shelves_migrated_to_room", true) }
return@withContext
}
val db = AppDatabase.getDatabase(context)
val shelfDao = db.shelfDao()
val validBookIds = recentFileDao.getAllFiles().map { it.bookId }.toSet()
shelfNames.forEach { name ->
val shelfId = UUID.nameUUIDFromBytes(name.toByteArray()).toString()
val timestamp = prefs.getLong("shelf_timestamp_$name", System.currentTimeMillis())
val isDeleted = prefs.getBoolean("shelf_deleted_$name", false)
val bookIds = prefs.getStringSet("shelf_content_$name", emptySet()) ?: emptySet()
val shelf = ShelfEntity(
id = shelfId, name = name, isSmart = false, smartRulesJson = null,
createdAt = timestamp, updatedAt = timestamp, isDeleted = isDeleted
)
shelfDao.insertShelf(shelf)
val crossRefs = bookIds.filter { it in validBookIds }.map { bookId ->
BookShelfCrossRef(bookId = bookId, shelfId = shelfId, addedAt = timestamp)
}
if (crossRefs.isNotEmpty()) {
shelfDao.insertBookShelfCrossRefs(crossRefs)
}
}
prefs.edit { putBoolean("is_shelves_migrated_to_room", true) }
Timber.i("Successfully migrated legacy shelves to Room.")
}
} }

View file

@ -0,0 +1,81 @@
package com.aryan.reader.data
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
@Serializable
enum class SmartField { TITLE, AUTHOR, PROGRESS, FILE_TYPE, FOLDER, TAG }
@Serializable
enum class SmartOperator { EQUALS, CONTAINS, GREATER_THAN, LESS_THAN }
@Serializable
data class SmartRule(
val field: SmartField,
val operator: SmartOperator,
val value: String
)
@Serializable
data class SmartCollectionDefinition(
val matchAll: Boolean = true,
val rules: List<SmartRule> = emptyList()
)
object SmartCollectionEngine {
private val json = Json {
encodeDefaults = true
ignoreUnknownKeys = true
}
fun toJson(definition: SmartCollectionDefinition): String = json.encodeToString(definition)
fun fromJson(json: String?): SmartCollectionDefinition? {
if (json.isNullOrBlank()) return null
return try {
this.json.decodeFromString<SmartCollectionDefinition>(json)
} catch (_: Exception) { null }
}
fun evaluate(book: RecentFileItem, definition: SmartCollectionDefinition): Boolean {
if (definition.rules.isEmpty()) return false
val results = definition.rules.map { rule ->
when (rule.field) {
SmartField.TITLE -> evaluateString(book.title ?: book.displayName, rule)
SmartField.AUTHOR -> evaluateString(book.author ?: "", rule)
SmartField.FILE_TYPE -> evaluateString(book.type.name, rule)
SmartField.FOLDER -> evaluateString(book.sourceFolderUri ?: "", rule)
SmartField.TAG -> evaluateTags(book.tags.map { it.name }, rule)
SmartField.PROGRESS -> evaluateNumber(book.progressPercentage ?: 0f, rule)
}
}
return if (definition.matchAll) results.all { it } else results.any { it }
}
private fun evaluateString(target: String, rule: SmartRule): Boolean {
return when (rule.operator) {
SmartOperator.EQUALS -> target.equals(rule.value, ignoreCase = true)
SmartOperator.CONTAINS -> target.contains(rule.value, ignoreCase = true)
else -> false
}
}
private fun evaluateNumber(target: Float, rule: SmartRule): Boolean {
val ruleValue = rule.value.toFloatOrNull() ?: return false
return when (rule.operator) {
SmartOperator.EQUALS -> target == ruleValue
SmartOperator.GREATER_THAN -> target > ruleValue
SmartOperator.LESS_THAN -> target < ruleValue
else -> false
}
}
private fun evaluateTags(tags: List<String>, rule: SmartRule): Boolean {
return when (rule.operator) {
SmartOperator.EQUALS -> tags.any { it.equals(rule.value, ignoreCase = true) }
SmartOperator.CONTAINS -> tags.any { it.contains(rule.value, ignoreCase = true) }
else -> false
}
}
}

View file

@ -0,0 +1,166 @@
// CalibreBundleExtractor.kt
package com.aryan.reader.epub
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.core.net.toUri
import com.aryan.reader.BookImporter
import com.aryan.reader.FileType
import com.aryan.reader.data.RecentFilesRepository
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.w3c.dom.Element
import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
import java.util.zip.ZipInputStream
import javax.xml.parsers.DocumentBuilderFactory
data class CalibreBundleResult(
val internalBookUri: Uri,
val type: FileType,
val title: String?,
val author: String?,
val description: String?,
val seriesName: String?,
val seriesIndex: Double?,
val coverCachePath: String?
)
object CalibreBundleExtractor {
suspend fun processZip(
context: Context,
zipUri: Uri,
bookId: String,
bookImporter: BookImporter,
recentFilesRepository: RecentFilesRepository
): CalibreBundleResult? = withContext(Dispatchers.IO) {
var tempBookFile: File? = null
var extractedType: FileType? = null
var ext = ""
var opfData: String? = null
var coverBytes: ByteArray? = null
try {
context.contentResolver.openInputStream(zipUri)?.use { inputStream ->
val zis = ZipInputStream(inputStream)
var entry = zis.nextEntry
Timber.d("CalibreExtractor: Started reading zip entries from $zipUri")
while (entry != null) {
val name = entry.name.lowercase()
Timber.d("CalibreExtractor: Found zip entry: $name")
if (!entry.isDirectory) {
if (name.endsWith(".opf")) {
opfData = String(zis.readBytes(), Charsets.UTF_8)
Timber.d("CalibreExtractor: Extracted OPF data, length=${opfData?.length}")
} else if (name == "cover.jpg" || name == "cover.jpeg" || name.endsWith(".jpg")) {
// Prefer exact 'cover.jpg' but grab the first image as fallback
if (coverBytes == null || name.startsWith("cover")) {
coverBytes = zis.readBytes()
Timber.d("CalibreExtractor: Extracted cover image from $name")
}
} else {
val type = when {
name.endsWith(".epub") -> FileType.EPUB
name.endsWith(".mobi") || name.endsWith(".azw3") -> FileType.MOBI
name.endsWith(".pdf") -> FileType.PDF
name.endsWith(".fb2") -> FileType.FB2
else -> null
}
if (type != null && tempBookFile == null) {
extractedType = type
ext = File(name).extension
tempBookFile = File(context.cacheDir, "temp_bundle_${bookId}.$ext")
FileOutputStream(tempBookFile!!).use { fos ->
zis.copyTo(fos)
}
Timber.d("CalibreExtractor: Extracted book file $name to temp file")
}
}
}
zis.closeEntry()
entry = zis.nextEntry
}
}
Timber.d("CalibreExtractor: Finished parsing zip. tempBookFile exists=${tempBookFile != null}, opfData exists=${opfData != null}, extractedType=$extractedType")
if (tempBookFile != null && opfData != null && extractedType != null) {
val finalBookFile = bookImporter.createBookFile("$bookId.$ext")
tempBookFile!!.renameTo(finalBookFile)
var coverPath: String? = null
if (coverBytes != null) {
val bitmap = BitmapFactory.decodeByteArray(coverBytes, 0, coverBytes!!.size)
if (bitmap != null) {
coverPath = recentFilesRepository.saveCoverToCache(bitmap, zipUri)
}
}
var title: String? = null
var author: String? = null
var description: String? = null
var seriesName: String? = null
var seriesIndex: Double? = null
try {
val factory = DocumentBuilderFactory.newInstance()
val builder = factory.newDocumentBuilder()
val document = builder.parse(ByteArrayInputStream(opfData!!.toByteArray(Charsets.UTF_8)))
val metadataNodes = document.getElementsByTagName("metadata")
Timber.d("CalibreExtractor: Parsed OPF XML. metadataNodes count: ${metadataNodes.length}")
if (metadataNodes.length > 0) {
val metadata = metadataNodes.item(0) as Element
val titleNodes = metadata.getElementsByTagName("dc:title")
Timber.d("CalibreExtractor: Found ${titleNodes.length} dc:title nodes")
if (titleNodes.length > 0) title = titleNodes.item(0).textContent
val authorNodes = metadata.getElementsByTagName("dc:creator")
Timber.d("CalibreExtractor: Found ${authorNodes.length} dc:creator nodes")
if (authorNodes.length > 0) author = authorNodes.item(0).textContent
val descNodes = metadata.getElementsByTagName("dc:description")
Timber.d("CalibreExtractor: Found ${descNodes.length} dc:description nodes")
if (descNodes.length > 0) description = descNodes.item(0).textContent
val metaNodes = metadata.getElementsByTagName("meta")
Timber.d("CalibreExtractor: Found ${metaNodes.length} meta nodes")
for (i in 0 until metaNodes.length) {
val meta = metaNodes.item(i) as Element
val nameAttr = meta.getAttribute("name")
val contentAttr = meta.getAttribute("content")
if (nameAttr == "calibre:series") seriesName = contentAttr
if (nameAttr == "calibre:series_index") seriesIndex = contentAttr.toDoubleOrNull()
}
}
} catch (e: Exception) {
Timber.e(e, "Failed to parse metadata.opf")
}
Timber.d("CalibreExtractor: Final extracted info - title=$title, author=$author, series=$seriesName, index=$seriesIndex")
return@withContext CalibreBundleResult(
internalBookUri = finalBookFile.toUri(),
type = extractedType!!,
title = title,
author = author,
description = description,
seriesName = seriesName,
seriesIndex = seriesIndex,
coverCachePath = coverPath
)
}
} catch (e: Exception) {
Timber.e(e, "Failed to process zip bundle")
} finally {
tempBookFile?.delete() // Cleanup if parsing failed midway
}
return@withContext null
}
}

View file

@ -46,5 +46,8 @@ data class EpubBook(
val extractionBasePath: String = "", val extractionBasePath: String = "",
val css: Map<String, String> = emptyMap(), val css: Map<String, String> = emptyMap(),
@Transient @Transient
val chaptersForPagination: List<EpubChapter> = chapters val chaptersForPagination: List<EpubChapter> = chapters,
val seriesName: String? = null,
val seriesIndex: Double? = null,
val description: String? = null,
) )

View file

@ -167,17 +167,14 @@ class EpubParser(private val context: Context) {
bookId: String, bookId: String,
shouldUseToc: Boolean = true, shouldUseToc: Boolean = true,
originalBookNameHint: String = "streamed_book", originalBookNameHint: String = "streamed_book",
parseContent: Boolean = true parseContent: Boolean = true,
extractionDirOverride: File? = null
): EpubBook { ): EpubBook {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream for bookId: $bookId") Timber.d("Parsing EPUB input stream for bookId: $bookId")
val extractionDir = File(context.cacheDir, "imported_file_$bookId") val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
if (extractionDir.exists()) {
extractionDir.deleteRecursively()
}
extractionDir.mkdirs()
val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir) val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir)
val filesMap: Map<String, EpubFile> val filesMap: Map<String, EpubFile>
@ -244,6 +241,24 @@ class EpubParser(private val context: Context) {
val metadataLanguage = val metadataLanguage =
document.metadata.selectFirstChildTag("dc:language")?.textContent ?: "en" document.metadata.selectFirstChildTag("dc:language")?.textContent ?: "en"
val metadataCoverId = getMetadataCoverId(document.metadata) val metadataCoverId = getMetadataCoverId(document.metadata)
val metadataDescription =
document.metadata.selectFirstChildTag("dc:description")?.textContent
Timber.d("EpubParser: Extracted OPF metadata: title='$metadataTitle', author='$metadataAuthor'")
var metadataSeriesName: String? = null
var metadataSeriesIndex: Double? = null
document.metadata.selectChildTag("meta")
.ifEmpty { document.metadata.selectChildTag("opf:meta") }
.forEach { meta ->
val nameAttr = meta.getAttributeValue("name")
val contentAttr = meta.getAttributeValue("content")
if (nameAttr == "calibre:series") metadataSeriesName = contentAttr
if (nameAttr == "calibre:series_index") metadataSeriesIndex = contentAttr?.toDoubleOrNull()
}
val opfRelativePath = document.opfFilePath val opfRelativePath = document.opfFilePath
val opfParentDir = File(opfRelativePath).parentFile ?: File("") val opfParentDir = File(opfRelativePath).parentFile ?: File("")
val manifestItems = getManifestItems(document.manifest, opfParentDir) val manifestItems = getManifestItems(document.manifest, opfParentDir)
@ -344,7 +359,10 @@ class EpubParser(private val context: Context) {
pageList = pageTargets, pageList = pageTargets,
tableOfContents = tableOfContents, tableOfContents = tableOfContents,
extractionBasePath = extractionBasePath, extractionBasePath = extractionBasePath,
css = cssContent css = cssContent,
seriesName = metadataSeriesName,
seriesIndex = metadataSeriesIndex,
description = metadataDescription
) )
} }

View file

@ -20,13 +20,11 @@ class Fb2Parser(private val context: Context) {
inputStream: InputStream, inputStream: InputStream,
bookId: String, bookId: String,
originalBookNameHint: String, originalBookNameHint: String,
parseContent: Boolean = true parseContent: Boolean = true,
extractionDirOverride: File? = null
): EpubBook = withContext(Dispatchers.IO) { ): EpubBook = withContext(Dispatchers.IO) {
File(context.cacheDir, "imported_file_$bookId").deleteRecursively() val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply {
if (!exists()) mkdirs()
}
var streamToParse = inputStream var streamToParse = inputStream
try { try {

View file

@ -0,0 +1,81 @@
package com.aryan.reader.epub
import android.content.Context
import java.io.File
import java.util.UUID
object ImportedFileCache {
private const val ACTIVE_PREFIX = "imported_file_"
private const val TEMP_PREFIX = "imported_file_tmp_"
private val invalidSegmentChars = Regex("[^A-Za-z0-9._-]+")
fun activeBookDir(context: Context, bookId: String): File {
return File(context.cacheDir, "$ACTIVE_PREFIX$bookId")
}
fun prepareActiveBookDir(context: Context, bookId: String): File {
return prepareDirectory(activeBookDir(context, bookId))
}
fun createTemporaryBookDir(context: Context, bookId: String, purpose: String): File {
val dirName = buildString {
append(TEMP_PREFIX)
append(purpose.toCacheSegment())
append('_')
append(bookMarker(bookId))
append('_')
append(UUID.randomUUID())
}
return prepareDirectory(File(context.cacheDir, dirName))
}
fun prepareDirectory(directory: File): File {
if (directory.exists()) {
directory.deleteRecursively()
}
directory.mkdirs()
return directory
}
fun clearBookCache(context: Context, bookId: String) {
activeBookDir(context, bookId).takeIf { it.exists() }?.deleteRecursively()
clearTemporaryBookDirs(context, bookId)
}
fun clearTemporaryBookDirs(context: Context, bookId: String) {
val marker = "_${bookMarker(bookId)}_"
context.cacheDir.listFiles()?.forEach { file ->
if (isTemporaryBookDir(file.name) && file.name.contains(marker)) {
file.deleteRecursively()
}
}
}
fun deleteStaleTemporaryBookDirs(
context: Context,
olderThanMillis: Long,
nowMillis: Long = System.currentTimeMillis()
) {
context.cacheDir.listFiles()?.forEach { file ->
if (isTemporaryBookDir(file.name) && nowMillis - file.lastModified() >= olderThanMillis) {
file.deleteRecursively()
}
}
}
fun isTemporaryBookDir(name: String): Boolean = name.startsWith(TEMP_PREFIX)
fun isActiveBookDir(name: String): Boolean {
return name.startsWith(ACTIVE_PREFIX) && !isTemporaryBookDir(name)
}
private fun bookMarker(bookId: String): String {
val normalized = bookId.toCacheSegment().ifBlank { "book" }.take(40)
val hash = bookId.hashCode().toLong() and 0xffffffffL
return "${normalized}_${hash.toString(16)}"
}
private fun String.toCacheSegment(): String {
return replace(invalidSegmentChars, "_").trim('_')
}
}

View file

@ -109,27 +109,18 @@ class MobiParser(private val context: Context) {
private external fun parseMobiFile(filePath: String): ParsedMobiData? private external fun parseMobiFile(filePath: String): ParsedMobiData?
companion object { companion object {
const val EXTRACTED_EPUB_DIR_NAME = "extracted_epubs"
init { init {
System.loadLibrary("mobi") System.loadLibrary("mobi")
System.loadLibrary("native-lib") 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( suspend fun createMobiBook(
inputStream: InputStream, inputStream: InputStream,
bookId: String, bookId: String,
originalBookNameHint: String, originalBookNameHint: String,
parseContent: Boolean = true parseContent: Boolean = true,
extractionDirOverride: File? = null
): EpubBook? = withContext(Dispatchers.IO) { ): EpubBook? = withContext(Dispatchers.IO) {
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir) val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
try { try {
@ -162,8 +153,8 @@ class MobiParser(private val context: Context) {
val bookTitle = parsedData.title ?: originalBookNameHint val bookTitle = parsedData.title ?: originalBookNameHint
val bookAuthor = parsedData.author ?: "Unknown Author" val bookAuthor = parsedData.author ?: "Unknown Author"
val extractionDir = File(context.cacheDir, "imported_file_$bookId") val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
extractionDir.mkdirs() ?: ImportedFileCache.prepareActiveBookDir(context, bookId)
val sequentialImageMap = parsedData.resources val sequentialImageMap = parsedData.resources
.filter { it.mediaType.startsWith("image/") } .filter { it.mediaType.startsWith("image/") }

View file

@ -29,12 +29,11 @@ class OdtParser(private val context: Context) {
bookId: String, bookId: String,
originalBookNameHint: String, originalBookNameHint: String,
isFlat: Boolean, isFlat: Boolean,
parseContent: Boolean = true parseContent: Boolean = true,
extractionDirOverride: File? = null
): EpubBook = withContext(Dispatchers.IO) { ): EpubBook = withContext(Dispatchers.IO) {
File(context.cacheDir, "imported_file_$bookId").deleteRecursively() val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
val extractionDir = File(context.cacheDir, "imported_file_$bookId").apply { ?: ImportedFileCache.prepareActiveBookDir(context, bookId)
if (!exists()) mkdirs()
}
val mathJaxFileName = "tex-mml-chtml.js" val mathJaxFileName = "tex-mml-chtml.js"
val mathJaxFile = File(extractionDir, mathJaxFileName) val mathJaxFile = File(extractionDir, mathJaxFileName)

View file

@ -41,11 +41,17 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
import java.io.InputStream import java.io.InputStream
import java.util.UUID import java.util.UUID
import java.util.zip.ZipFile
class SingleFileImporter(private val context: Context) { class SingleFileImporter(private val context: Context) {
private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val jsonSerializer = Json { ignoreUnknownKeys = true; encodeDefaults = true }
companion object {
private const val MAX_DOCX_ARCHIVE_BYTES = 64L * 1024L * 1024L
private const val MAX_DOCX_XML_BYTES = 48L * 1024L * 1024L
}
suspend fun importSingleFile( suspend fun importSingleFile(
inputStream: InputStream, inputStream: InputStream,
type: FileType, type: FileType,
@ -104,16 +110,21 @@ class SingleFileImporter(private val context: Context) {
var inQuotes = false var inQuotes = false
for (char in line) { for (char in line) {
if (char == '\"') { when (char) {
'\"' -> {
inQuotes = !inQuotes inQuotes = !inQuotes
} else if (char == delimiter && !inQuotes) { }
val escaped = current.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") delimiter if !inQuotes -> {
val escaped =
current.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
writer.write("<td>$escaped</td>") writer.write("<td>$escaped</td>")
current.clear() current.clear()
} else { }
else -> {
current.append(char) current.append(char)
} }
} }
}
val escapedFinal = current.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") val escapedFinal = current.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
writer.write("<td>$escapedFinal</td></tr>\n") writer.write("<td>$escapedFinal</td></tr>\n")
@ -663,15 +674,29 @@ class SingleFileImporter(private val context: Context) {
val parseStart = System.currentTimeMillis() val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint") Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")
val htmlContent = inputStream.use { stream -> val sourceDocxFile = File(context.cacheDir, "temp_docx_source_${UUID.randomUUID()}.docx")
val tempFile = File(context.cacheDir, "temp_docx_${UUID.randomUUID()}.html")
try {
inputStream.use { stream ->
FileOutputStream(sourceDocxFile).use { output ->
stream.copyTo(output)
}
}
validateDocxForImport(sourceDocxFile, originalBookNameHint)
val htmlContent = sourceDocxFile.inputStream().use { stream ->
try {
val converter = DocumentConverter() val converter = DocumentConverter()
converter.convertToHtml(stream).value ?: "" converter.convertToHtml(stream).value ?: ""
} catch (oom: OutOfMemoryError) {
Timber.e(oom, "DOCX conversion ran out of memory for $originalBookNameHint")
throw IllegalStateException("This DOCX file is too large to open safely on this device.")
}
} }
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms") Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms")
val tempFile = File(context.cacheDir, "temp_docx_${UUID.randomUUID()}.html")
try {
FileOutputStream(tempFile).bufferedWriter().use { writer -> FileOutputStream(tempFile).bufferedWriter().use { writer ->
val title = originalBookNameHint.substringBeforeLast(".") val title = originalBookNameHint.substringBeforeLast(".")
writer.write("<!DOCTYPE html>\n<html>\n<head>\n<title>$title</title>\n</head>\n<body>\n") writer.write("<!DOCTYPE html>\n<html>\n<head>\n<title>$title</title>\n</head>\n<body>\n")
@ -683,12 +708,41 @@ class SingleFileImporter(private val context: Context) {
return@withContext parseHtml(tempStream, originalBookNameHint, bookId, parseContent) return@withContext parseHtml(tempStream, originalBookNameHint, bookId, parseContent)
} }
} finally { } finally {
if (sourceDocxFile.exists()) {
sourceDocxFile.delete()
}
if (tempFile.exists()) { if (tempFile.exists()) {
tempFile.delete() tempFile.delete()
} }
} }
} }
private fun validateDocxForImport(sourceDocxFile: File, originalBookNameHint: String) {
val archiveBytes = sourceDocxFile.length()
if (archiveBytes > MAX_DOCX_ARCHIVE_BYTES) {
throw IllegalStateException("This DOCX file is too large to open safely on this device.")
}
ZipFile(sourceDocxFile).use { zip ->
var totalXmlBytes = 0L
val entries = zip.entries()
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (entry.isDirectory) continue
if (entry.name.endsWith(".xml", ignoreCase = true)) {
val entrySize = entry.size
if (entrySize > 0) {
totalXmlBytes += entrySize
}
if (totalXmlBytes > MAX_DOCX_XML_BYTES) {
Timber.w("DOCX XML payload too large for import: file=$originalBookNameHint xmlBytes=$totalXmlBytes")
throw IllegalStateException("This DOCX file is too large to open safely on this device.")
}
}
}
}
}
private fun writeHtmlChapter( private fun writeHtmlChapter(
extractionDir: File, extractionDir: File,
bookId: String, bookId: String,

View file

@ -92,6 +92,8 @@ import java.io.BufferedReader
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStreamReader import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
private fun getFontCssInjection(): String { private fun getFontCssInjection(): String {
return """ return """
@font-face { font-family: 'Merriweather'; src: url('file:///android_asset/fonts/merriweather.ttf'); } @font-face { font-family: 'Merriweather'; src: url('file:///android_asset/fonts/merriweather.ttf'); }
@ -313,6 +315,17 @@ class FootnoteJsBridge(
} }
} }
@Suppress("unused")
class LinkNavJsBridge(
private val currentChapterTitle: String
) {
@JavascriptInterface
fun onLinkClicked(href: String, epubType: String, linkText: String) {
Timber.tag(TAG_LINK_NAV)
.d("[JS-CLICK] href='$href', epub:type='$epubType', label='$linkText' | currentChapter='$currentChapterTitle'")
}
}
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@Composable @Composable
fun ChapterWebView( fun ChapterWebView(
@ -336,6 +349,8 @@ fun ChapterWebView(
currentFontSize: Float, currentFontSize: Float,
currentLineHeight: Float, currentLineHeight: Float,
currentParagraphGap: Float, currentParagraphGap: Float,
currentImageSize: Float,
currentHorizontalMargin: Float,
onChapterInitiallyScrolled: () -> Unit, onChapterInitiallyScrolled: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onTap: () -> Unit, onTap: () -> Unit,
@ -370,6 +385,7 @@ fun ChapterWebView(
onAutoScrollChapterEnd: () -> Unit = {}, onAutoScrollChapterEnd: () -> Unit = {},
activeHighlightPalette: List<HighlightColor>, activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit, onUpdatePalette: (Int, HighlightColor) -> Unit,
onInternalLinkClick: (String) -> Unit,
activeTextureId: String? = null activeTextureId: String? = null
) { ) {
Timber.d( Timber.d(
@ -471,6 +487,8 @@ fun ChapterWebView(
currentFontSize, currentFontSize,
currentLineHeight, currentLineHeight,
currentParagraphGap, currentParagraphGap,
currentImageSize,
currentHorizontalMargin,
currentFontFamily, currentFontFamily,
currentTextAlign currentTextAlign
) { ) {
@ -550,6 +568,11 @@ fun ChapterWebView(
consoleMessage?.let { consoleMessage?.let {
val message = it.message() val message = it.message()
when { when {
message.startsWith("LINK_NAV:") -> {
Timber.tag(TAG_LINK_NAV)
.d("JS -> ${message.substringAfter("LINK_NAV: ")}")
}
message.startsWith("FootnoteDiag:") -> { message.startsWith("FootnoteDiag:") -> {
Timber.tag("FootnoteDiag") Timber.tag("FootnoteDiag")
.d("JS -> ${message.substringAfter("FootnoteDiag: ")}") .d("JS -> ${message.substringAfter("FootnoteDiag: ")}")
@ -653,16 +676,31 @@ fun ChapterWebView(
}, "FootnoteBridge" }, "FootnoteBridge"
) )
addJavascriptInterface(
LinkNavJsBridge(chapterTitle), "LinkNavBridge"
)
webViewClient = object : WebViewClient() { webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading( override fun shouldOverrideUrlLoading(
view: WebView?, request: WebResourceRequest? view: WebView?, request: WebResourceRequest?
): Boolean { ): Boolean {
val url = request?.url?.toString() val url = request?.url?.toString()
if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) { if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) {
Timber.d("Intercepted external link: $url") Timber.tag(TAG_LINK_NAV)
.d("[EXTERNAL-INTERCEPT] url='$url' from chapter '$chapterTitle'")
showExternalLinkDialog = url showExternalLinkDialog = url
return true return true
} }
if (url != null && url.startsWith("file://")) {
Timber.tag(TAG_LINK_NAV)
.d("[INTERNAL-LINK-INTERCEPTED] url='$url' from chapter '$chapterTitle'")
onInternalLinkClick(url)
return true
}
if (url != null) {
Timber.tag(TAG_LINK_NAV)
.d("[INTERNAL-LINK-PASSED] url='$url' from chapter '$chapterTitle' — allowing WebView to handle")
}
return false return false
} }
@ -744,7 +782,7 @@ fun ChapterWebView(
} }
view?.evaluateJavascript( view?.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);", "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
null null
) )
@ -768,10 +806,59 @@ fun ChapterWebView(
} }
} else if (!initialFragmentId.isNullOrBlank()) { } else if (!initialFragmentId.isNullOrBlank()) {
Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId") Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId")
view?.evaluateJavascript( val js = """
"javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }", (function() {
null var targetId = '$initialFragmentId';
) var el = document.getElementById(targetId) || document.querySelector('[name="' + targetId + '"]');
if (el) {
var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10);
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
return -2;
}
if (window.virtualization && window.virtualization.chunksData) {
for (var i = 0; i < window.virtualization.chunksData.length; i++) {
var chunkHtml = window.virtualization.chunksData[i];
if (chunkHtml && (chunkHtml.indexOf('id="' + targetId + '"') !== -1 || chunkHtml.indexOf('name="' + targetId + '"') !== -1 || chunkHtml.indexOf("id='" + targetId + "'") !== -1 || chunkHtml.indexOf("name='" + targetId + "'") !== -1)) {
return i;
}
}
}
return -1;
})()
""".trimIndent()
view?.evaluateJavascript(js) { result ->
val chunkIdx = result?.toIntOrNull() ?: -1
if (chunkIdx >= 0) {
for (i in 0..chunkIdx) {
onChunkRequested(i)
}
val scrollJs = """
(function() {
var chunkIndex = $chunkIdx;
var fragmentId = '$initialFragmentId';
setTimeout(function() {
var chunkDiv = document.querySelector('.chunk-container[data-chunk-index="' + chunkIndex + '"]');
if (chunkDiv && chunkDiv.innerHTML === "" && window.virtualization && window.virtualization.chunksData[chunkIndex]) {
chunkDiv.innerHTML = window.virtualization.chunksData[chunkIndex];
chunkDiv.style.height = "";
}
setTimeout(function() {
var el = document.getElementById(fragmentId) || document.querySelector('[name="' + fragmentId + '"]');
if (el) {
var targetScrollY = window.scrollY + el.getBoundingClientRect().top - (window.VIEWPORT_PADDING_TOP + 10);
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
} else if (chunkDiv) {
var targetScrollY = window.scrollY + chunkDiv.getBoundingClientRect().top - window.VIEWPORT_PADDING_TOP;
window.scrollTo({ top: targetScrollY, behavior: 'auto' });
}
}, 50);
}, 200);
})()
""".trimIndent()
view.evaluateJavascript(scrollJs, null)
}
}
onChapterInitiallyScrolled() onChapterInitiallyScrolled()
scrollActionTaken = true scrollActionTaken = true
} else if (initialScrollTarget != null) { } else if (initialScrollTarget != null) {
@ -859,7 +946,7 @@ fun ChapterWebView(
) )
webView.evaluateJavascript( webView.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);", "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
null null
) )

View file

@ -1378,6 +1378,7 @@ fun TtsOverlayControls(
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode, currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
isCollapsed: Boolean, isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit, onCollapseChange: (Boolean) -> Unit,
onLocateCurrentChunk: () -> Unit,
onOpenTtsSettings: () -> Unit, onOpenTtsSettings: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -1504,6 +1505,14 @@ fun TtsOverlayControls(
} }
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) {
Icon(
painterResource(R.drawable.pin_drop),
"Locate current chunk",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) { IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant) Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
} }

View file

@ -24,17 +24,7 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility 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.animation.animateContentSize
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -42,69 +32,88 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.drag
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider import androidx.compose.material3.Slider
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.content.edit import androidx.core.content.edit
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity import com.aryan.reader.data.CustomFontEntity
import java.io.File import java.io.File
import androidx.compose.material3.Switch import kotlin.math.roundToInt
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
const val SETTINGS_PREFS_NAME = "epub_reader_settings" const val SETTINGS_PREFS_NAME = "epub_reader_settings"
private const val TEXT_ALIGN_KEY = "reader_text_align" private const val TEXT_ALIGN_KEY = "reader_text_align"
private const val FONT_SIZE_KEY = "reader_font_size" private const val FONT_SIZE_KEY = "reader_font_size"
private const val LINE_HEIGHT_KEY = "reader_line_height" private const val LINE_HEIGHT_KEY = "reader_line_height"
private const val PARAGRAPH_GAP_KEY = "reader_paragraph_gap" private const val PARAGRAPH_GAP_KEY = "reader_paragraph_gap"
private const val IMAGE_SIZE_KEY = "reader_image_size"
private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed" private const val AUTO_SCROLL_SPEED_KEY = "reader_auto_scroll_speed"
private const val FONT_FAMILY_KEY = "reader_font_family" private const val FONT_FAMILY_KEY = "reader_font_family"
private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled" private const val TAP_TO_NAVIGATE_ENABLED_KEY = "tap_to_navigate_enabled"
@ -116,6 +125,8 @@ private const val PULL_TO_TURN_ENABLED_KEY = "reader_pull_to_turn_enabled"
const val DEFAULT_FONT_SIZE_VAL = 1.0f const val DEFAULT_FONT_SIZE_VAL = 1.0f
const val DEFAULT_LINE_HEIGHT_VAL = 1.0f const val DEFAULT_LINE_HEIGHT_VAL = 1.0f
const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f const val DEFAULT_PARAGRAPH_GAP_VAL = 1.0f
const val DEFAULT_IMAGE_SIZE_VAL = 1.0f
const val DEFAULT_HORIZONTAL_MARGIN_VAL = 1.0f
private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate" private const val TTS_SPEECH_RATE_KEY = "tts_speech_rate"
private const val TTS_PITCH_KEY = "tts_pitch" private const val TTS_PITCH_KEY = "tts_pitch"
@ -170,6 +181,8 @@ data class FormatSettings(
val fontSize: Float, val fontSize: Float,
val lineHeight: Float, val lineHeight: Float,
val paragraphGap: Float, val paragraphGap: Float,
val imageSize: Float,
val horizontalMargin: Float,
val font: ReaderFont, val font: ReaderFont,
val customPath: String?, val customPath: String?,
val textAlign: ReaderTextAlign val textAlign: ReaderTextAlign
@ -179,8 +192,11 @@ private const val FORMAT_IS_LOCAL_PREFIX = "format_is_local_"
private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_" private const val LOCAL_FONT_SIZE_PREFIX = "local_font_size_"
private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_" private const val LOCAL_LINE_HEIGHT_PREFIX = "local_line_height_"
private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_" private const val LOCAL_PARAGRAPH_GAP_PREFIX = "local_paragraph_gap_"
private const val LOCAL_IMAGE_SIZE_PREFIX = "local_image_size_"
private const val LOCAL_HORIZONTAL_MARGIN_PREFIX = "local_horizontal_margin_"
private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_" private const val LOCAL_FONT_FAMILY_PREFIX = "local_font_family_"
private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_" private const val LOCAL_TEXT_ALIGN_PREFIX = "local_text_align_"
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
fun loadFormatIsLocal(context: Context, bookId: String): Boolean { fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -198,6 +214,8 @@ fun saveLocalReaderSettings(
fontSize: Float, fontSize: Float,
lineHeight: Float, lineHeight: Float,
paragraphGap: Float, paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
fontFamily: ReaderFont, fontFamily: ReaderFont,
customFontPath: String?, customFontPath: String?,
textAlign: ReaderTextAlign textAlign: ReaderTextAlign
@ -207,6 +225,8 @@ fun saveLocalReaderSettings(
putFloat(LOCAL_FONT_SIZE_PREFIX + bookId, fontSize) putFloat(LOCAL_FONT_SIZE_PREFIX + bookId, fontSize)
putFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, lineHeight) putFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, lineHeight)
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap) putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
if (customFontPath != null) { if (customFontPath != null) {
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath") putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
} else { } else {
@ -260,6 +280,14 @@ fun loadPullToTurnMultiplier(context: Context): Float {
return prefs.getFloat(PULL_TO_TURN_MULTIPLIER_KEY, 1.0f) return prefs.getFloat(PULL_TO_TURN_MULTIPLIER_KEY, 1.0f)
} }
fun loadHorizontalMargin(context: Context): Float {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
if (prefs.contains(HORIZONTAL_MARGIN_KEY)) {
return prefs.getFloat(HORIZONTAL_MARGIN_KEY, DEFAULT_HORIZONTAL_MARGIN_VAL)
}
return if (loadRemoveEdgePadding(context)) 0f else DEFAULT_HORIZONTAL_MARGIN_VAL
}
fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings { fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): FormatSettings {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -281,6 +309,18 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
prefs.getFloat(PARAGRAPH_GAP_KEY, DEFAULT_PARAGRAPH_GAP_VAL) prefs.getFloat(PARAGRAPH_GAP_KEY, DEFAULT_PARAGRAPH_GAP_VAL)
} }
val imageSize = if (isLocal && prefs.contains(LOCAL_IMAGE_SIZE_PREFIX + bookId)) {
prefs.getFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, DEFAULT_IMAGE_SIZE_VAL)
} else {
prefs.getFloat(IMAGE_SIZE_KEY, DEFAULT_IMAGE_SIZE_VAL)
}
val horizontalMargin = if (isLocal && prefs.contains(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId)) {
prefs.getFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, DEFAULT_HORIZONTAL_MARGIN_VAL)
} else {
loadHorizontalMargin(context)
}
val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) { val savedFontVal = if (isLocal && prefs.contains(LOCAL_FONT_FAMILY_PREFIX + bookId)) {
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
} else { } else {
@ -300,7 +340,16 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
} }
val textAlign = ReaderTextAlign.entries.find { it.id == alignId } ?: ReaderTextAlign.DEFAULT val textAlign = ReaderTextAlign.entries.find { it.id == alignId } ?: ReaderTextAlign.DEFAULT
return FormatSettings(fontSize, lineHeight, paragraphGap, font, customPath, textAlign) return FormatSettings(
fontSize = fontSize,
lineHeight = lineHeight,
paragraphGap = paragraphGap,
imageSize = imageSize,
horizontalMargin = horizontalMargin,
font = font,
customPath = customPath,
textAlign = textAlign
)
} }
fun getComposeFontFamily( fun getComposeFontFamily(
@ -339,6 +388,8 @@ fun saveReaderSettings(
fontSize: Float, fontSize: Float,
lineHeight: Float, lineHeight: Float,
paragraphGap: Float, paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
fontFamily: ReaderFont, fontFamily: ReaderFont,
customFontPath: String?, customFontPath: String?,
textAlign: ReaderTextAlign textAlign: ReaderTextAlign
@ -348,6 +399,8 @@ fun saveReaderSettings(
putFloat(FONT_SIZE_KEY, fontSize) putFloat(FONT_SIZE_KEY, fontSize)
putFloat(LINE_HEIGHT_KEY, lineHeight) putFloat(LINE_HEIGHT_KEY, lineHeight)
putFloat(PARAGRAPH_GAP_KEY, paragraphGap) putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
putFloat(IMAGE_SIZE_KEY, imageSize)
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
if (customFontPath != null) { if (customFontPath != null) {
putString(FONT_FAMILY_KEY, "custom|$customFontPath") putString(FONT_FAMILY_KEY, "custom|$customFontPath")
} else { } else {
@ -387,6 +440,7 @@ fun loadVolumeScrollSetting(context: Context): Boolean {
return prefs.getBoolean(VOLUME_SCROLL_ENABLED_KEY, false) return prefs.getBoolean(VOLUME_SCROLL_ENABLED_KEY, false)
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ReaderTextFormatPanel( fun ReaderTextFormatPanel(
isVisible: Boolean, isVisible: Boolean,
@ -394,8 +448,12 @@ fun ReaderTextFormatPanel(
onFontSizeChange: (Float) -> Unit, onFontSizeChange: (Float) -> Unit,
currentLineHeight: Float, currentLineHeight: Float,
onLineHeightChange: (Float) -> Unit, onLineHeightChange: (Float) -> Unit,
currentParagraphGap: Float, // NEW currentParagraphGap: Float,
onParagraphGapChange: (Float) -> Unit, // NEW onParagraphGapChange: (Float) -> Unit,
currentImageSize: Float,
onImageSizeChange: (Float) -> Unit,
currentHorizontalMargin: Float,
onHorizontalMarginChange: (Float) -> Unit,
currentFont: ReaderFont, currentFont: ReaderFont,
currentCustomFontName: String?, currentCustomFontName: String?,
onFontOptionClick: () -> Unit, onFontOptionClick: () -> Unit,
@ -404,27 +462,28 @@ fun ReaderTextFormatPanel(
onReset: () -> Unit, onReset: () -> Unit,
isLocalMode: Boolean, isLocalMode: Boolean,
onLocalModeToggle: (Boolean) -> Unit, onLocalModeToggle: (Boolean) -> Unit,
onClose: () -> Unit, onClose: () -> Unit
modifier: Modifier = Modifier
) { ) {
AnimatedVisibility( if (isVisible) {
visible = isVisible, val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(), ModalBottomSheet(
modifier = modifier onDismissRequest = onClose,
sheetState = sheetState,
scrimColor = Color.Transparent,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f),
contentWindowInsets = { WindowInsets.navigationBars }
) { ) {
Surface( val configuration = LocalConfiguration.current
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), val maxSheetHeight = (configuration.screenHeightDp * 0.7f).dp
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
tonalElevation = 0.dp, Column(
shadowElevation = 8.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.animateContentSize() .heightIn(max = maxSheetHeight)
) { .verticalScroll(rememberScrollState())
Column( .padding(horizontal = 16.dp, vertical = 8.dp)
modifier = Modifier.padding(16.dp) .padding(bottom = 24.dp)
) { ) {
// Header Row (Local/Global + Close/Reset) // Header Row (Local/Global + Close/Reset)
Row( Row(
@ -500,7 +559,7 @@ fun ReaderTextFormatPanel(
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
) )
// Font Button (Full width) // Font Button
Surface( Surface(
onClick = onFontOptionClick, onClick = onFontOptionClick,
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
@ -540,7 +599,7 @@ fun ReaderTextFormatPanel(
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
// Alignment Button (Full width Segmented) // Alignment Button (Segmented)
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant, color = MaterialTheme.colorScheme.surfaceVariant,
@ -586,49 +645,61 @@ fun ReaderTextFormatPanel(
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp) modifier = Modifier.padding(start = 4.dp, bottom = 12.dp)
) )
// Sliders // Resolve the string once outside the lambdas
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { val originalLabel = stringResource(R.string.label_original)
// Size val noneLabel = stringResource(R.string.label_none)
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.label_font_size), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp)) // Wide, smooth sliders without dots
Slider( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
FormatSlider(
label = stringResource(R.string.label_font_size),
value = currentFontSize, value = currentFontSize,
onValueChange = onFontSizeChange, onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f, valueRange = 0.5f..3.0f,
steps = 24, formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
modifier = Modifier.weight(1f)
) )
Text(if (currentFontSize in 0.99f..1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentFontSize), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
} FormatSlider(
// Lines label = stringResource(R.string.label_line_height),
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.label_line_height), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
Slider(
value = currentLineHeight, value = currentLineHeight,
onValueChange = onLineHeightChange, onValueChange = onLineHeightChange,
valueRange = 1.0f..3.0f, valueRange = 1.0f..3.0f,
steps = 19, formatValue = { if (it <= 1.01f) originalLabel else "%.1fx".format(it) }
modifier = Modifier.weight(1f)
) )
Text(if (currentLineHeight <= 1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentLineHeight), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
} FormatSlider(
// Paragraph Gap label = stringResource(R.string.label_paragraph_gap),
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Text(stringResource(R.string.label_paragraph_gap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp))
Slider(
value = currentParagraphGap, value = currentParagraphGap,
onValueChange = onParagraphGapChange, onValueChange = onParagraphGapChange,
valueRange = 0.0f..3.0f, valueRange = 0.0f..3.0f,
steps = 29, formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
modifier = Modifier.weight(1f)
) )
Text(if (currentParagraphGap in 0.99f..1.01f) stringResource(R.string.label_original) else "%.1fx".format(currentParagraphGap), style = MaterialTheme.typography.labelMedium, modifier = Modifier.width(40.dp), textAlign = TextAlign.End)
FormatSlider(
label = stringResource(R.string.label_image_size),
value = currentImageSize,
onValueChange = onImageSizeChange,
valueRange = 0.5f..2.0f,
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
)
FormatSlider(
label = stringResource(R.string.label_horizontal_margin),
value = currentHorizontalMargin,
onValueChange = onHorizontalMarginChange,
valueRange = 0.0f..3.0f,
formatValue = {
when {
it <= 0.01f -> noneLabel
it in 0.99f..1.01f -> originalLabel
else -> "%.1fx".format(it)
} }
} }
Spacer(Modifier.height(8.dp)) )
}
} }
} }
} }
@ -761,8 +832,6 @@ fun VisualOptionsSheet(
onPageInfoModeChange: (PageInfoMode) -> Unit, onPageInfoModeChange: (PageInfoMode) -> Unit,
pullToTurnEnabled: Boolean, pullToTurnEnabled: Boolean,
onPullToTurnChange: (Boolean) -> Unit, onPullToTurnChange: (Boolean) -> Unit,
removeEdgePadding: Boolean,
onRemoveEdgePaddingChange: (Boolean) -> Unit,
pullToTurnMultiplier: Float, pullToTurnMultiplier: Float,
onPullToTurnMultiplierChange: (Float) -> Unit, onPullToTurnMultiplierChange: (Float) -> Unit,
onDismiss: () -> Unit onDismiss: () -> Unit
@ -859,30 +928,6 @@ fun VisualOptionsSheet(
} }
} }
} }
Spacer(modifier = Modifier.height(24.dp))
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier
.fillMaxWidth()
.clickable { onRemoveEdgePaddingChange(!removeEdgePadding) }
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Column(modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.visual_options_edge_padding), style = MaterialTheme.typography.titleMedium)
Text(stringResource(R.string.visual_options_edge_padding_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Spacer(modifier = Modifier.width(16.dp))
Switch(checked = removeEdgePadding, onCheckedChange = { onRemoveEdgePaddingChange(it) })
}
}
Spacer(modifier = Modifier.height(32.dp)) Spacer(modifier = Modifier.height(32.dp))
} }
} }
@ -923,3 +968,137 @@ fun <T> OptionSegmentedControl(
} }
} }
} }
@Composable
fun CustomCanvasSlider(
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
modifier: Modifier = Modifier
) {
val fraction = ((value - valueRange.start) / (valueRange.endInclusive - valueRange.start)).coerceIn(0f, 1f)
val activeColor = MaterialTheme.colorScheme.primary
val inactiveColor = MaterialTheme.colorScheme.surfaceVariant
val thumbColor = MaterialTheme.colorScheme.primary
Box(
modifier = modifier
.height(24.dp) // Keeps the touch target height slim
.pointerInput(valueRange) {
awaitEachGesture {
val down = awaitFirstDown()
fun update(offset: Offset) {
val newFraction = (offset.x / size.width.toFloat()).coerceIn(0f, 1f)
val rawValue = valueRange.start + newFraction * (valueRange.endInclusive - valueRange.start)
// Snap to 0.1 intervals for consistent formatting
onValueChange((rawValue * 10f).roundToInt() / 10f)
}
update(down.position)
drag(down.id) { change ->
change.consume()
update(change.position)
}
}
}
) {
Canvas(modifier = Modifier.fillMaxSize()) {
val trackHeight = 4.dp.toPx()
val cornerRadius = CornerRadius(trackHeight / 2, trackHeight / 2)
val trackY = (size.height - trackHeight) / 2
// Draw Inactive Track
drawRoundRect(
color = inactiveColor,
topLeft = Offset(0f, trackY),
size = Size(size.width, trackHeight),
cornerRadius = cornerRadius
)
// Draw Active Track
val activeWidth = fraction * size.width
drawRoundRect(
color = activeColor,
topLeft = Offset(0f, trackY),
size = Size(activeWidth, trackHeight),
cornerRadius = cornerRadius
)
// Draw Thumb
val thumbRadius = 8.dp.toPx()
drawCircle(
color = thumbColor,
radius = thumbRadius,
center = Offset(
x = activeWidth.coerceIn(thumbRadius, size.width - thumbRadius),
y = size.height / 2
)
)
}
}
}
@Composable
fun FormatSlider(
label: String,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
stepSize: Float = 0.1f,
formatValue: (Float) -> String
) {
Column(modifier = Modifier.fillMaxWidth()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 4.dp, end = 4.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = formatValue(value),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.fillMaxWidth()
) {
IconButton(
onClick = {
val newValue = (value - stepSize).coerceAtLeast(valueRange.start)
onValueChange((newValue * 10f).roundToInt() / 10f)
},
modifier = Modifier.size(32.dp) // Slimmer buttons
) {
Icon(Icons.Default.Remove, contentDescription = "Decrease", tint = MaterialTheme.colorScheme.primary)
}
// Using our new CustomCanvasSlider here!
CustomCanvasSlider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange,
modifier = Modifier.weight(1f)
)
IconButton(
onClick = {
val newValue = (value + stepSize).coerceAtMost(valueRange.endInclusive)
onValueChange((newValue * 10f).roundToInt() / 10f)
},
modifier = Modifier.size(32.dp) // Slimmer buttons
) {
Icon(Icons.Default.Add, contentDescription = "Increase", tint = MaterialTheme.colorScheme.primary)
}
}
}
}

View file

@ -213,6 +213,7 @@ fun TtsSessionObserver(
fun TtsHighlightHandler( fun TtsHighlightHandler(
ttsState: TtsPlaybackManager.TtsState, ttsState: TtsPlaybackManager.TtsState,
currentRenderMode: RenderMode, currentRenderMode: RenderMode,
currentChapterIndex: Int,
webViewRef: WebView?, webViewRef: WebView?,
paginator: IPaginator?, paginator: IPaginator?,
pagerState: PagerState, pagerState: PagerState,
@ -223,14 +224,42 @@ fun TtsHighlightHandler(
val text = ttsState.currentText val text = ttsState.currentText
val cfi = ttsState.sourceCfi val cfi = ttsState.sourceCfi
val offset = ttsState.startOffsetInSource val offset = ttsState.startOffsetInSource
val activeTtsChapterIndex = ttsState.chapterIndex ?: ttsChapterIndex
if (
currentRenderMode == RenderMode.VERTICAL_SCROLL &&
activeTtsChapterIndex != null &&
activeTtsChapterIndex != currentChapterIndex
) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
"Vertical highlight skipped because visible chapter differs from active TTS chapter. " +
"visibleChapter=$currentChapterIndex activeTtsChapter=$activeTtsChapterIndex " +
"cfi=${cfi?.take(48)} offset=$offset"
)
webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null)
return@LaunchedEffect
}
if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) { if (!text.isNullOrBlank() && !cfi.isNullOrBlank() && offset != -1) {
val escapedText = escapeJsString(text) val escapedText = escapeJsString(text)
val escapedCfi = escapeJsString(cfi) val escapedCfi = escapeJsString(cfi)
val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);" val jsCommand = "javascript:window.highlightFromCfi('$escapedCfi', '$escapedText', $offset);"
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
"Applying vertical TTS highlight. visibleChapter=$currentChapterIndex " +
"activeTtsChapter=$activeTtsChapterIndex cfi=${cfi.take(48)} " +
"offset=$offset textLen=${text.length}"
)
}
webViewRef?.evaluateJavascript(jsCommand, null) webViewRef?.evaluateJavascript(jsCommand, null)
} else { } else {
if (!ttsState.isPlaying && !ttsState.isLoading) { if (!ttsState.isPlaying && !ttsState.isLoading) {
if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
Timber.tag("TTS_CHAPTER_CHANGE_DIAG").d(
"Removing vertical TTS highlight because playback is idle. " +
"visibleChapter=$currentChapterIndex activeTtsChapter=$activeTtsChapterIndex"
)
}
webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null) webViewRef?.evaluateJavascript("javascript:window.removeHighlight();", null)
} }
} }
@ -298,6 +327,7 @@ private fun handleVerticalAutoAdvance(
bookTitle = epubBookTitle, bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title, chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() }, coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
chapterIndex = currentTtsChapterIndex,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -327,6 +357,7 @@ private fun handleVerticalAutoAdvance(
bookTitle = epubBookTitle, bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(nextIdx)?.title, chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() }, coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
chapterIndex = nextIdx,
ttsMode = currentTtsMode, ttsMode = currentTtsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token
@ -401,6 +432,7 @@ private fun handlePaginatedAutoAdvance(
bookTitle = epubBookTitle, bookTitle = epubBookTitle,
chapterTitle = chapterTitle, chapterTitle = chapterTitle,
coverImageUri = coverUriString, coverImageUri = coverUriString,
chapterIndex = chapterToTry,
ttsMode = ttsMode, ttsMode = ttsMode,
playbackSource = "READER", playbackSource = "READER",
authToken = token authToken = token

View file

@ -96,8 +96,12 @@ object ExternalDictionaryHelper {
putExtra(Intent.EXTRA_PROCESS_TEXT, query) putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true) putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName) setPackage(packageName)
// Only add NEW_TASK if we don't have an Activity context,
// preventing task switch animations for NoDisplay apps like Notification Dictionary
if (context.getActivity() == null) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
}
if (processTextIntent.resolveActivity(pm) != null) { if (processTextIntent.resolveActivity(pm) != null) {
context.startActivity(processTextIntent) context.startActivity(processTextIntent)
@ -148,8 +152,10 @@ object ExternalDictionaryHelper {
putExtra(Intent.EXTRA_PROCESS_TEXT, query) putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true) putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName) setPackage(packageName)
if (context.getActivity() == null) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
}
if (translateIntent.resolveActivity(pm) != null) { if (translateIntent.resolveActivity(pm) != null) {
context.startActivity(translateIntent) context.startActivity(translateIntent)
return return
@ -162,8 +168,10 @@ object ExternalDictionaryHelper {
putExtra(Intent.EXTRA_PROCESS_TEXT, query) putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true) putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName) setPackage(packageName)
if (context.getActivity() == null) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
} }
}
if (processTextIntent.resolveActivity(pm) != null) { if (processTextIntent.resolveActivity(pm) != null) {
context.startActivity(processTextIntent) context.startActivity(processTextIntent)
@ -281,4 +289,15 @@ object ExternalDictionaryHelper {
return apps.sortedBy { it.label } return apps.sortedBy { it.label }
} }
private fun Context.getActivity(): android.app.Activity? {
var currentContext = this
while (currentContext is android.content.ContextWrapper) {
if (currentContext is android.app.Activity) {
return currentContext
}
currentContext = currentContext.baseContext
}
return null
}
} }

View file

@ -0,0 +1,13 @@
package com.aryan.reader.ml
import android.graphics.Bitmap
import android.graphics.RectF
data class SpeechBubble(
val bounds: RectF,
val maskBitmap: Bitmap? = null
)
interface ISpeechBubbleDetector : AutoCloseable {
fun detectBubbles(bitmap: Bitmap, confidenceThreshold: Float = 0.1f): List<SpeechBubble>
}

View file

@ -119,7 +119,8 @@ class BookPaginator(
private val context: Context, private val context: Context,
private val mathMLRenderer: MathMLRenderer, private val mathMLRenderer: MathMLRenderer,
private val userTextAlign: TextAlign?, private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float private val paragraphGapMultiplier: Float,
private val imageSizeMultiplier: Float
) : IPaginator { ) : IPaginator {
override var totalPageCount by mutableIntStateOf(0) override var totalPageCount by mutableIntStateOf(0)
private set private set
@ -267,7 +268,16 @@ class BookPaginator(
} }
private fun generateConfigurationHash(): Int { private fun generateConfigurationHash(): Int {
val configString = "w:${constraints.maxWidth}-h:${constraints.maxHeight}-fs:${textStyle.fontSize.value}-ta:$userTextAlign-pg:$paragraphGapMultiplier" val configString = buildString {
append("w:${constraints.maxWidth}")
append("-h:${constraints.maxHeight}")
append("-fs:${textStyle.fontSize.value}")
append("-lh:${textStyle.lineHeight.value}")
append("-ff:${textStyle.fontFamily}")
append("-ta:$userTextAlign")
append("-pg:$paragraphGapMultiplier")
append("-img:$imageSizeMultiplier")
}
val hash = configString.hashCode() val hash = configString.hashCode()
return hash return hash
} }
@ -722,7 +732,8 @@ class BookPaginator(
textMeasurer = textMeasurer, textMeasurer = textMeasurer,
constraints = constraints, constraints = constraints,
textStyle = textStyle, textStyle = textStyle,
density = density density = density,
imageSizeMultiplier = imageSizeMultiplier
) )
Timber.d("paginateChapter: Calling PaginatorLogic for chapter $chapterIndex.") Timber.d("paginateChapter: Calling PaginatorLogic for chapter $chapterIndex.")
val pages = paginate( val pages = paginate(
@ -971,31 +982,7 @@ class BookPaginator(
return@launch return@launch
} }
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex) findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete)
val chapterStartPage = calculateAccurateStartIndex(targetChapterIndex)
if (chapterPages == null) {
Timber.e("Href Navigation failed: Could not paginate target chapter $targetChapterIndex.")
return@launch
}
var targetPageInChapter = 0
if (anchor != null) {
Timber.d("Searching for anchor '$anchor' in chapter $targetChapterIndex.")
pageLoop@ for ((pageIndex, page) in chapterPages.withIndex()) {
for (block in page.content) {
if (block.elementId == anchor) {
targetPageInChapter = pageIndex
Timber.i("Found anchor '$anchor' on page $pageIndex in chapter $targetChapterIndex")
break@pageLoop
}
}
}
}
val finalPageIndex = chapterStartPage + targetPageInChapter
Timber.i("Href navigation complete. Final page index: $finalPageIndex")
withContext(Dispatchers.Main) { onNavigationComplete(finalPageIndex) }
} }
} }

View file

@ -144,7 +144,8 @@ class PaginatedReaderViewModel : ViewModel() {
context = context.applicationContext, context = context.applicationContext,
mathMLRenderer = mathMLRenderer, mathMLRenderer = mathMLRenderer,
userTextAlign = null, userTextAlign = null,
paragraphGapMultiplier = paragraphGapMultiplier paragraphGapMultiplier = paragraphGapMultiplier,
imageSizeMultiplier = 1.0f
) )
paginator = newPaginator paginator = newPaginator

View file

@ -50,7 +50,8 @@ class SuspendingAndroidBlockMeasurementProvider(
private val textMeasurer: TextMeasurer, private val textMeasurer: TextMeasurer,
private val constraints: Constraints, private val constraints: Constraints,
private val textStyle: TextStyle, private val textStyle: TextStyle,
private val density: Density private val density: Density,
private val imageSizeMultiplier: Float
) : BlockMeasurementProvider { ) : BlockMeasurementProvider {
override suspend fun measure(block: ContentBlock): Int { override suspend fun measure(block: ContentBlock): Int {
@ -60,7 +61,8 @@ class SuspendingAndroidBlockMeasurementProvider(
constraints = constraints, constraints = constraints,
defaultStyle = textStyle, defaultStyle = textStyle,
headerStyle = textStyle.copy(fontWeight = FontWeight.Bold), headerStyle = textStyle.copy(fontWeight = FontWeight.Bold),
density = density density = density,
imageSizeMultiplier = imageSizeMultiplier
) )
} }
@ -79,29 +81,12 @@ class SuspendingAndroidBlockMeasurementProvider(
val imageBlock = block.floatedImage val imageBlock = block.floatedImage
val (imageWidthPx, imageHeightPx) = run { val (imageWidthPx, imageHeightPx) = run {
val imageStyle = imageBlock.style measureScaledImageSizePx(
val intrinsicWidth = imageBlock.intrinsicWidth block = imageBlock,
val intrinsicHeight = imageBlock.intrinsicHeight density = density,
maxWidthPx = constraints.maxWidth.toFloat(),
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) { imageSizeMultiplier = imageSizeMultiplier
0f to 0f )
} else {
val aspectRatio = intrinsicHeight / intrinsicWidth
val renderWidth = with(density) {
var w = intrinsicWidth
if (imageStyle.width != Dp.Unspecified) {
w = imageStyle.width.toPx()
}
if (imageStyle.maxWidth != Dp.Unspecified) {
w = w.coerceAtMost(imageStyle.maxWidth.toPx())
}
w.coerceAtMost(constraints.maxWidth.toFloat())
}
renderWidth to (renderWidth * aspectRatio)
}
} }
if (imageWidthPx <= 0 || imageHeightPx <= 0) { if (imageWidthPx <= 0 || imageHeightPx <= 0) {
@ -668,62 +653,25 @@ private suspend fun measureBlockHeight(
constraints: Constraints, constraints: Constraints,
defaultStyle: TextStyle, defaultStyle: TextStyle,
headerStyle: TextStyle, headerStyle: TextStyle,
density: Density density: Density,
imageSizeMultiplier: Float = 1.0f
): Int { ): Int {
var verticalPaddingPx = 0f val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
var horizontalPaddingPx = 0f val verticalPaddingPx = boxMetrics.verticalPaddingPx
var verticalBorderPx = 0f val verticalBorderPx = boxMetrics.verticalBorderPx
var horizontalBorderPx = 0f val adjustedConstraints = boxMetrics.contentConstraints
with(density) {
verticalPaddingPx = block.style.padding.top.toPx() + block.style.padding.bottom.toPx()
horizontalPaddingPx = block.style.padding.left.toPx() + block.style.padding.right.toPx()
verticalBorderPx = (block.style.borderTop?.width?.toPx() ?: 0f) + (block.style.borderBottom?.width?.toPx() ?: 0f)
horizontalBorderPx = (block.style.borderLeft?.width?.toPx() ?: 0f) + (block.style.borderRight?.width?.toPx() ?: 0f)
}
val isBorderBox = block.style.boxSizing == "border-box"
val specifiedWidthDp = block.style.width
val specifiedMaxWidthDp = block.style.maxWidth
val blockOuterWidthPx = with(density) {
var effectiveWidthPx = constraints.maxWidth.toFloat()
if (specifiedWidthDp != Dp.Unspecified) {
effectiveWidthPx = specifiedWidthDp.toPx()
}
if (specifiedMaxWidthDp != Dp.Unspecified) {
val maxWidthPx = specifiedMaxWidthDp.toPx()
if (effectiveWidthPx > maxWidthPx) {
effectiveWidthPx = maxWidthPx
}
}
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
}
val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified) {
(blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx)
} else if (isBorderBox) {
(blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx)
} else {
blockOuterWidthPx
}
val adjustedConstraints = constraints.copy(
maxWidth = contentMaxWidth.roundToInt().coerceAtLeast(0),
maxHeight = Constraints.Infinity
)
val contentHeight = when (block) { val contentHeight = when (block) {
is ParagraphBlock -> { is ParagraphBlock -> {
val paragraphStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign)
val height = withContext(Dispatchers.Main) { val height = withContext(Dispatchers.Main) {
textMeasurer.measure( textMeasurer.measure(
text = block.content, text = block.content,
style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign), style = paragraphStyle,
constraints = adjustedConstraints constraints = adjustedConstraints
).size.height ).size.height
} }
height height + centeredTextSafetyPaddingPx(paragraphStyle, density)
} }
is HeaderBlock -> { is HeaderBlock -> {
val style = headerStyle.copy( val style = headerStyle.copy(
@ -736,27 +684,15 @@ private suspend fun measureBlockHeight(
constraints = adjustedConstraints constraints = adjustedConstraints
).size.height ).size.height
} }
height height + centeredTextSafetyPaddingPx(style, density)
} }
is ImageBlock -> { is ImageBlock -> {
val imageIntrinsicWidth = block.intrinsicWidth val measuredHeight = measureScaledImageHeightPx(
val imageIntrinsicHeight = block.intrinsicHeight block = block,
density = density,
val styledHeightPx = if (block.style.height.isSpecified) with(density) { block.style.height.toPx() } else null contentMaxWidth = adjustedConstraints.maxWidth.toFloat(),
val styledWidthPx = if (block.style.width.isSpecified) with(density) { block.style.width.toPx() } else null imageSizeMultiplier = imageSizeMultiplier
) ?: with(density) { 250.dp.toPx() }
val measuredHeight = when {
styledHeightPx != null && styledHeightPx > 0f -> styledHeightPx
styledWidthPx != null && styledWidthPx > 0f && imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
styledWidthPx * aspectRatio
}
imageIntrinsicWidth != null && imageIntrinsicHeight != null && imageIntrinsicWidth > 0 -> {
val aspectRatio = imageIntrinsicHeight / imageIntrinsicWidth
contentMaxWidth * aspectRatio
}
else -> with(density) { 250.dp.toPx() }
}
val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt() val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt()
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})") Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
@ -767,14 +703,15 @@ private suspend fun measureBlockHeight(
height height
} }
is QuoteBlock -> { is QuoteBlock -> {
val quoteStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign)
val height = withContext(Dispatchers.Main) { val height = withContext(Dispatchers.Main) {
textMeasurer.measure( textMeasurer.measure(
text = block.content, text = block.content,
style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign), style = quoteStyle,
constraints = adjustedConstraints constraints = adjustedConstraints
).size.height ).size.height
} }
height height + centeredTextSafetyPaddingPx(quoteStyle, density)
} }
is ListItemBlock -> { is ListItemBlock -> {
val markerWidthPx = with(density) { 32.dp.toPx() }.toInt() val markerWidthPx = with(density) { 32.dp.toPx() }.toInt()
@ -811,7 +748,7 @@ private suspend fun measureBlockHeight(
val cellConstraints = adjustedConstraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0)) val cellConstraints = adjustedConstraints.copy(maxWidth = cellMaxWidth.coerceAtLeast(0))
val cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density) val cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
var cellDecorationHeight = 0f var cellDecorationHeight = 0f
with(density) { with(density) {
@ -829,35 +766,18 @@ private suspend fun measureBlockHeight(
val imageBlock = block.floatedImage val imageBlock = block.floatedImage
val (imageWidthPx, imageHeightPx) = run { val (imageWidthPx, imageHeightPx) = run {
val imageStyle = imageBlock.style measureScaledImageSizePx(
val intrinsicWidth = imageBlock.intrinsicWidth block = imageBlock,
val intrinsicHeight = imageBlock.intrinsicHeight density = density,
maxWidthPx = adjustedConstraints.maxWidth.toFloat(),
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) { imageSizeMultiplier = imageSizeMultiplier
0f to 0f )
} else {
val aspectRatio = intrinsicHeight / intrinsicWidth
val renderWidth = with(density) {
var w = intrinsicWidth
if (imageStyle.width != Dp.Unspecified) {
w = imageStyle.width.toPx()
}
if (imageStyle.maxWidth != Dp.Unspecified) {
w = w.coerceAtMost(imageStyle.maxWidth.toPx())
}
w.coerceAtMost(adjustedConstraints.maxWidth.toFloat())
}
renderWidth to (renderWidth * aspectRatio)
}
} }
// If image has no size, it can't float. Just measure the paragraphs. // If image has no size, it can't float. Just measure the paragraphs.
if (imageWidthPx <= 0 || imageHeightPx <= 0) { if (imageWidthPx <= 0 || imageHeightPx <= 0) {
val height = block.paragraphsToWrap.sumOf { p -> val height = block.paragraphsToWrap.sumOf { p ->
measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density) measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
} }
return height return height
} }
@ -949,10 +869,10 @@ private suspend fun measureBlockHeight(
val isRow = block.style.flexDirection == "row" val isRow = block.style.flexDirection == "row"
val height = if (isRow) { val height = if (isRow) {
block.children.maxOfOrNull { child -> block.children.maxOfOrNull { child ->
measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density) measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
} ?: 0 } ?: 0
} else { } else {
calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density) calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
} }
height height
} }
@ -980,7 +900,7 @@ private suspend fun measureBlockHeight(
} }
} }
val specifiedHeightDp = block.style.height val specifiedHeightDp = block.style.height
val finalHeight = if (isBorderBox && specifiedHeightDp != Dp.Unspecified) { val finalHeight = if (block.style.boxSizing == "border-box" && specifiedHeightDp != Dp.Unspecified) {
with(density) { specifiedHeightDp.toPx().roundToInt() } with(density) { specifiedHeightDp.toPx().roundToInt() }
} else { } else {
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt() (contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
@ -1000,6 +920,10 @@ private suspend fun splitParagraphBlock(
): Pair<ParagraphBlock, ParagraphBlock>? { ): Pair<ParagraphBlock, ParagraphBlock>? {
val text = block.content val text = block.content
if (text.isEmpty()) return null if (text.isEmpty()) return null
val boxMetrics = computeBlockBoxMetrics(block, constraints, density)
val paragraphConstraints = boxMetrics.contentConstraints
val paragraphStyle = textStyle.copy(textAlign = block.textAlign ?: textStyle.textAlign)
val centeredSafetyPaddingPx = centeredTextSafetyPaddingPx(paragraphStyle, density)
val decorationTop = with(density) { val decorationTop = with(density) {
block.style.padding.top.toPx() + (block.style.borderTop?.width?.toPx() ?: 0f) block.style.padding.top.toPx() + (block.style.borderTop?.width?.toPx() ?: 0f)
@ -1009,7 +933,7 @@ private suspend fun splitParagraphBlock(
block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f) block.style.padding.bottom.toPx() + (block.style.borderBottom?.width?.toPx() ?: 0f)
}.roundToInt() }.roundToInt()
val availableTextHeight = availableHeight - decorationTop - decorationBottom val availableTextHeight = availableHeight - decorationTop - decorationBottom - centeredSafetyPaddingPx
Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight") Timber.tag("PAGINATION_DEBUG").d("SplitPara: totalAvail=$availableHeight, topDec=$decorationTop, botDec=$decorationBottom, textAvail=$availableTextHeight")
@ -1021,8 +945,8 @@ private suspend fun splitParagraphBlock(
val layoutResult = withContext(Dispatchers.Main) { val layoutResult = withContext(Dispatchers.Main) {
textMeasurer.measure( textMeasurer.measure(
text = text, text = text,
style = textStyle, style = paragraphStyle,
constraints = constraints.copy(maxHeight = Constraints.Infinity) constraints = paragraphConstraints
) )
} }
@ -1036,7 +960,7 @@ private suspend fun splitParagraphBlock(
var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight.toFloat()) var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight.toFloat())
if (layoutResult.getLineBottom(lastVisibleLine) > availableHeight.toFloat()) { if (layoutResult.getLineBottom(lastVisibleLine) > availableTextHeight.toFloat()) {
lastVisibleLine-- lastVisibleLine--
} }
@ -1056,7 +980,8 @@ private suspend fun splitParagraphBlock(
val part2Layout = withContext(Dispatchers.Main) { val part2Layout = withContext(Dispatchers.Main) {
textMeasurer.measure( textMeasurer.measure(
text = part2CheckText, text = part2CheckText,
constraints = constraints style = paragraphStyle,
constraints = paragraphConstraints
) )
} }
if (part2Layout.lineCount == 1) { if (part2Layout.lineCount == 1) {
@ -1149,11 +1074,12 @@ private suspend fun calculateContentHeightWithMargins(
constraints: Constraints, constraints: Constraints,
defaultStyle: TextStyle, defaultStyle: TextStyle,
headerStyle: TextStyle, headerStyle: TextStyle,
density: Density density: Density,
imageSizeMultiplier: Float = 1.0f
): Int { ): Int {
var totalHeight = 0 var totalHeight = 0
children.forEachIndexed { index, child -> children.forEachIndexed { index, child ->
val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density) val childHeight = measureBlockHeight(child, textMeasurer, constraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
val margin = with(density) { val margin = with(density) {
if (index > 0) { if (index > 0) {
val prevMargin = children[index - 1].style.margin.bottom.toPx() val prevMargin = children[index - 1].style.margin.bottom.toPx()
@ -1172,6 +1098,117 @@ private suspend fun calculateContentHeightWithMargins(
return totalHeight return totalHeight
} }
private data class BlockBoxMetrics(
val verticalPaddingPx: Float,
val verticalBorderPx: Float,
val contentConstraints: Constraints
)
private fun computeBlockBoxMetrics(
block: ContentBlock,
constraints: Constraints,
density: Density
): BlockBoxMetrics {
val verticalPaddingPx: Float
val horizontalPaddingPx: Float
val verticalBorderPx: Float
val horizontalBorderPx: Float
with(density) {
verticalPaddingPx = block.style.padding.top.toPx() + block.style.padding.bottom.toPx()
horizontalPaddingPx = block.style.padding.left.toPx() + block.style.padding.right.toPx()
verticalBorderPx = (block.style.borderTop?.width?.toPx() ?: 0f) + (block.style.borderBottom?.width?.toPx() ?: 0f)
horizontalBorderPx = (block.style.borderLeft?.width?.toPx() ?: 0f) + (block.style.borderRight?.width?.toPx() ?: 0f)
}
val isBorderBox = block.style.boxSizing == "border-box"
val specifiedWidthDp = block.style.width
val specifiedMaxWidthDp = block.style.maxWidth
val blockOuterWidthPx = with(density) {
var effectiveWidthPx = constraints.maxWidth.toFloat()
if (specifiedWidthDp != Dp.Unspecified) {
effectiveWidthPx = specifiedWidthDp.toPx()
}
if (specifiedMaxWidthDp != Dp.Unspecified) {
val maxWidthPx = specifiedMaxWidthDp.toPx()
if (effectiveWidthPx > maxWidthPx) {
effectiveWidthPx = maxWidthPx
}
}
effectiveWidthPx.coerceAtMost(constraints.maxWidth.toFloat())
}
val contentMaxWidth = if (specifiedWidthDp == Dp.Unspecified || isBorderBox) {
blockOuterWidthPx - horizontalPaddingPx - horizontalBorderPx
} else {
blockOuterWidthPx
}
return BlockBoxMetrics(
verticalPaddingPx = verticalPaddingPx,
verticalBorderPx = verticalBorderPx,
contentConstraints = constraints.copy(
maxWidth = contentMaxWidth.roundToInt().coerceAtLeast(0),
maxHeight = Constraints.Infinity
)
)
}
private fun centeredTextSafetyPaddingPx(
style: TextStyle,
density: Density
): Int {
if (style.textAlign != androidx.compose.ui.text.style.TextAlign.Center) return 0
val fallbackLineHeight = if (style.fontSize.isSpecified) {
style.fontSize * 1.2f
} else {
16.sp * 1.2f
}
val effectiveLineHeight = if (style.lineHeight.isSpecified) style.lineHeight else fallbackLineHeight
return with(density) { effectiveLineHeight.toPx().roundToInt() }
}
private fun measureScaledImageHeightPx(
block: ImageBlock,
density: Density,
contentMaxWidth: Float,
imageSizeMultiplier: Float
): Float? = measureScaledImageSizePx(
block = block,
density = density,
maxWidthPx = contentMaxWidth,
imageSizeMultiplier = imageSizeMultiplier
).second.takeIf { it > 0f }
private fun measureScaledImageSizePx(
block: ImageBlock,
density: Density,
maxWidthPx: Float,
imageSizeMultiplier: Float
): Pair<Float, Float> {
val intrinsicWidth = block.intrinsicWidth
val intrinsicHeight = block.intrinsicHeight
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f || intrinsicHeight <= 0f) {
return 0f to 0f
}
val aspectRatio = intrinsicHeight / intrinsicWidth
val baseWidth = with(density) {
if (block.style.width.isSpecified) block.style.width.toPx() else maxWidthPx
}
var scaledWidth = baseWidth * imageSizeMultiplier
if (block.style.maxWidth.isSpecified) {
scaledWidth = scaledWidth.coerceAtMost(with(density) { block.style.maxWidth.toPx() } * imageSizeMultiplier)
}
scaledWidth = scaledWidth.coerceAtMost(maxWidthPx)
return scaledWidth to (scaledWidth * aspectRatio)
}
private fun zeroOutBottomMargin(blocks: MutableList<ContentBlock>) { private fun zeroOutBottomMargin(blocks: MutableList<ContentBlock>) {
if (blocks.isNotEmpty()) { if (blocks.isNotEmpty()) {
val lastBlock = blocks.last() val lastBlock = blocks.last()

View file

@ -198,7 +198,7 @@ abstract class BookCacheDao {
ConfigurationCache::class, ConfigurationCache::class,
AnchorIndexEntry::class AnchorIndexEntry::class
], ],
version = 7, version = 8,
exportSchema = false exportSchema = false
) )
abstract class BookCacheDatabase : RoomDatabase() { abstract class BookCacheDatabase : RoomDatabase() {

View file

@ -25,7 +25,7 @@ import androidx.room.ForeignKey
import androidx.room.Index import androidx.room.Index
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
const val LATEST_PROCESSING_VERSION = 7 const val LATEST_PROCESSING_VERSION = 8
@Entity(tableName = "processed_books") @Entity(tableName = "processed_books")
data class ProcessedBook( data class ProcessedBook(

View file

@ -26,10 +26,8 @@ import com.aryan.reader.pdf.data.PdfAnnotation
object DemoAnnotationGenerator { object DemoAnnotationGenerator {
// --- SVG Configuration ---
private const val SVG_WIDTH = 800f private const val SVG_WIDTH = 800f
// Extracted from your Figma SVG
private val DECORATIVE_DOTS = listOf( private val DECORATIVE_DOTS = listOf(
DotData(120f, 90f, 5f, Color(0xFFF59E0B), 0.7f), DotData(120f, 90f, 5f, Color(0xFFF59E0B), 0.7f),
DotData(680f, 210f, 6f, Color(0xFFEC4899), 0.7f), DotData(680f, 210f, 6f, Color(0xFFEC4899), 0.7f),
@ -72,47 +70,36 @@ object DemoAnnotationGenerator {
fun generateDemoAnnotations(pageIndex: Int): List<PdfAnnotation> { fun generateDemoAnnotations(pageIndex: Int): List<PdfAnnotation> {
val annotations = mutableListOf<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 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 scaleX = targetWidthPercent / SVG_WIDTH
val scaleY = scaleX // Keep uniform scale in abstract space
// Center offsets (0.5 is middle of page) // Center offsets (0.5 is middle of page)
val startX = (1f - targetWidthPercent) / 2f val startX = (1f - targetWidthPercent) / 2f
val startY = 0.4f // Position slightly above center vertically val startY = 0.2f
var currentTime = System.currentTimeMillis() var currentTime = System.currentTimeMillis()
// Helper to transform SVG points to PDF Page Points
fun transformPoint(x: Float, y: Float): PdfPoint { fun transformPoint(x: Float, y: Float): PdfPoint {
val pdfX = startX + (x * scaleX) val pdfX = startX + (x * scaleX)
val pdfY = startY + (y * scaleY) val pdfY = startY + (y * scaleX)
return PdfPoint(pdfX, pdfY, currentTime) return PdfPoint(pdfX, pdfY, currentTime)
} }
// 1. Render Decorative Dots
DECORATIVE_DOTS.forEach { dot -> DECORATIVE_DOTS.forEach { dot ->
val pdfPoint = transformPoint(dot.cx, dot.cy) 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( val points = listOf(
pdfPoint, pdfPoint,
pdfPoint.copy(x = pdfPoint.x + 0.0001f, timestamp = currentTime + 10) pdfPoint.copy(x = pdfPoint.x + 0.0001f, timestamp = currentTime + 10)
) )
// Convert SVG radius to stroke width
val relativeThickness = (dot.r / SVG_WIDTH) * 2.5f val relativeThickness = (dot.r / SVG_WIDTH) * 2.5f
annotations.add( annotations.add(
PdfAnnotation( PdfAnnotation(
type = AnnotationType.INK, type = AnnotationType.INK,
inkType = InkType.PEN, // Standard pen for dots inkType = InkType.PEN,
pageIndex = pageIndex, pageIndex = pageIndex,
points = points, points = points,
color = dot.color.copy(alpha = dot.alpha), color = dot.color.copy(alpha = dot.alpha),
@ -122,7 +109,6 @@ object DemoAnnotationGenerator {
currentTime += 50 currentTime += 50
} }
// 2. Render Text ("Try Episteme!")
val textPaths = splitSvgPaths(TEXT_STROKES_DATA) val textPaths = splitSvgPaths(TEXT_STROKES_DATA)
textPaths.forEach { pathString -> textPaths.forEach { pathString ->
val path = PathParser.createPathFromPathData(pathString) val path = PathParser.createPathFromPathData(pathString)
@ -130,7 +116,6 @@ object DemoAnnotationGenerator {
if (flattenedPoints.isNotEmpty()) { if (flattenedPoints.isNotEmpty()) {
val pdfPoints = flattenedPoints.mapIndexed { _, p -> val pdfPoints = flattenedPoints.mapIndexed { _, p ->
// Increment time to simulate drawing speed for Fountain Pen physics
currentTime += 8 currentTime += 8
transformPoint(p.x, p.y).copy(timestamp = currentTime) transformPoint(p.x, p.y).copy(timestamp = currentTime)
} }
@ -138,18 +123,17 @@ object DemoAnnotationGenerator {
annotations.add( annotations.add(
PdfAnnotation( PdfAnnotation(
type = AnnotationType.INK, type = AnnotationType.INK,
inkType = InkType.FOUNTAIN_PEN, // Handwriting looks best with this inkType = InkType.FOUNTAIN_PEN,
pageIndex = pageIndex, pageIndex = pageIndex,
points = pdfPoints, points = pdfPoints,
color = Color(0xFF418377), // Updated Green color = Color(0xFF418377),
strokeWidth = 0.004f // Fine tip strokeWidth = 0.004f
) )
) )
currentTime += 150 // Pen lift delay currentTime += 150
} }
} }
// 3. Render Underline
val underlinePath = PathParser.createPathFromPathData(UNDERLINE_DATA) val underlinePath = PathParser.createPathFromPathData(UNDERLINE_DATA)
val underlinePointsRaw = flattenPath(underlinePath) val underlinePointsRaw = flattenPath(underlinePath)
val underlinePdfPoints = underlinePointsRaw.map { p -> val underlinePdfPoints = underlinePointsRaw.map { p ->
@ -160,10 +144,10 @@ object DemoAnnotationGenerator {
annotations.add( annotations.add(
PdfAnnotation( PdfAnnotation(
type = AnnotationType.INK, type = AnnotationType.INK,
inkType = InkType.PEN, // Consistent width for underline inkType = InkType.PEN,
pageIndex = pageIndex, pageIndex = pageIndex,
points = underlinePdfPoints, points = underlinePdfPoints,
color = Color(0xFFEC4899).copy(alpha = 0.6f), // Pink color = Color(0xFFEC4899).copy(alpha = 0.6f),
strokeWidth = 0.005f strokeWidth = 0.005f
) )
) )
@ -171,8 +155,6 @@ object DemoAnnotationGenerator {
return annotations 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 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) private data class PointF(val x: Float, val y: Float)
@ -180,11 +162,9 @@ object DemoAnnotationGenerator {
* Android's Path doesn't give us points directly. We use approximate(). * Android's Path doesn't give us points directly. We use approximate().
*/ */
private fun flattenPath(path: Path): List<PointF> { 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 approximation = path.approximate(0.5f)
val points = mutableListOf<PointF>() val points = mutableListOf<PointF>()
// approximation array format: [t0, x0, y0, t1, x1, y1, ...]
var i = 0 var i = 0
while (i < approximation.size) { while (i < approximation.size) {
val x = approximation[i + 1] val x = approximation[i + 1]
@ -204,15 +184,12 @@ object DemoAnnotationGenerator {
val result = mutableListOf<String>() val result = mutableListOf<String>()
rawPaths.forEach { fullPathString -> rawPaths.forEach { fullPathString ->
// Clean up and standardize
val cleanStr = fullPathString.trim() val cleanStr = fullPathString.trim()
// Split by "M" (Move command).
val parts = cleanStr.split("M") val parts = cleanStr.split("M")
parts.forEach { part -> parts.forEach { part ->
if (part.isNotBlank()) { if (part.isNotBlank()) {
// Re-prepend M because split removed it
result.add("M ${part.trim()}") result.add("M ${part.trim()}")
} }
} }

View file

@ -1,9 +1,24 @@
// PdfNavigationDrawerContent.kt
package com.aryan.reader.pdf package com.aryan.reader.pdf
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.aspectRatio
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.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.itemsIndexed
@ -13,29 +28,260 @@ import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons 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.material.icons.filled.MoreVert
import androidx.compose.material3.* import androidx.compose.material3.AlertDialog
import androidx.compose.runtime.* import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
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.OutlinedTextField
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONArray
import timber.log.Timber
import androidx.core.graphics.createBitmap
private const val MAX_FIXED_RECURSION = 128
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
/**
* Patches the library bug where siblings are truncated due to depth-state leakage.
*/
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
val tag = "PdfTocFix"
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
return try {
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
val docUInstance = documentField.get(this) ?: return getTableOfContents()
// 2. Get the 'nativeDocument' field from PdfDocumentU
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
// 3. Get the native pointer (long) from PdfDocumentU
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
val mNativeDocPtr = ptrField.get(docUInstance) as Long
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
val nClass = nativeDocInstance.javaClass
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
val topLevel = mutableListOf<Bookmark>()
val visited = mutableSetOf<Long>()
/**
* Corrected traversal: Iterative for siblings, recursive for children.
*/
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
var currentPtr = startPtr
var itemIndex = 0
while (currentPtr != 0L) {
if (visited.contains(currentPtr)) break
visited.add(currentPtr)
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
val bookmark = Bookmark().apply {
this.mNativePtr = currentPtr
this.title = title
this.pageIdx = pageIdx
}
parentList.add(bookmark)
// Recursive dive into children
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
walk(bookmark.children, firstChild, level + 1)
}
// Iterative move to next sibling
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
itemIndex++
}
}
// 5. Start from the root (Pass 0L as primitive long)
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
if (firstRoot != 0L) {
walk(topLevel, firstRoot, 0)
}
if (topLevel.isEmpty()) {
Timber.tag(tag).w("No items found, falling back to library.")
getTableOfContents()
} else {
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
topLevel
}
} catch (e: Exception) {
Timber.tag(tag).e(e, "Reflection traversal critical error.")
this.getTableOfContents()
}
}
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
val entries = mutableListOf<TocEntry>()
for ((index, bookmark) in bookmarks.withIndex()) {
val title = bookmark.title ?: "Untitled Chapter"
val childCount = bookmark.children.size
Timber.tag("PdfTocDebug").d(
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
)
entries.add(
TocEntry(
title = title,
pageIndex = bookmark.pageIdx.toInt(),
nestLevel = level
)
)
if (childCount > 0) {
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
entries.addAll(flattenToc(bookmark.children, level + 1))
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
}
}
return entries
}
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
if (bookmarksJson.isNullOrBlank()) return emptySet()
return try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).mapNotNull { i ->
try {
val json = jsonArray.getJSONObject(i)
PdfBookmark(
pageIndex = json.getInt("pageIndex"),
title = json.getString("title"),
totalPages = json.getInt("totalPages")
)
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmark from JSON object")
null
}
}.toSet()
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
emptySet()
}
}
@Composable
internal fun PdfTocTreeItem(
label: String,
nestLevel: 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 = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width((16 * nestLevel).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 (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 16.dp)
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
internal fun PdfNavigationDrawerContent( internal fun PdfNavigationDrawerContent(
pdfDocument: ReaderDocument?,
flatTableOfContents: List<TocEntry>, flatTableOfContents: List<TocEntry>,
bookmarks: Set<PdfBookmark>, bookmarks: Set<PdfBookmark>,
userHighlights: List<PdfUserHighlight>, userHighlights: List<PdfUserHighlight>,
currentPage: Int, currentPage: Int,
totalPages: Int,
customHighlightColors: Map<PdfHighlightColor, Color>, customHighlightColors: Map<PdfHighlightColor, Color>,
onPageSelected: (Int) -> Unit, onPageSelected: (Int) -> Unit,
onRenameBookmark: (PdfBookmark, String) -> Unit, onRenameBookmark: (PdfBookmark, String) -> Unit,
@ -44,11 +290,15 @@ internal fun PdfNavigationDrawerContent(
onNoteRequested: (String?) -> Unit, onNoteRequested: (String?) -> Unit,
onCloseDrawer: () -> Unit onCloseDrawer: () -> Unit
) { ) {
val drawerPagerState = rememberPagerState(pageCount = { 3 }) val drawerPagerState = rememberPagerState(pageCount = { 4 })
val drawerScope = rememberCoroutineScope() val drawerScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = drawerPagerState.currentPage) { ScrollableTabRow(
selectedTabIndex = drawerPagerState.currentPage,
edgePadding = 8.dp,
modifier = Modifier.fillMaxWidth()
) {
Tab(selected = drawerPagerState.currentPage == 0, onClick = { Tab(selected = drawerPagerState.currentPage == 0, onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(0) } drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
}, text = { Text("Chapters") }) }, text = { Text("Chapters") })
@ -68,6 +318,14 @@ internal fun PdfNavigationDrawerContent(
text = { Text("Highlights") }, text = { Text("Highlights") },
modifier = Modifier.testTag("HighlightsTab") modifier = Modifier.testTag("HighlightsTab")
) )
Tab(
selected = drawerPagerState.currentPage == 3,
onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(3) }
},
text = { Text("Pages") },
modifier = Modifier.testTag("PagesTab")
)
} }
HorizontalPager( HorizontalPager(
@ -531,6 +789,130 @@ internal fun PdfNavigationDrawerContent(
} }
} }
} }
3 -> { // Pages Page
val listState = rememberLazyListState()
val pageRows = remember(totalPages) { (0 until totalPages).chunked(3) }
val currentRowIndex = currentPage / 3
Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
TextButton(
onClick = {
drawerScope.launch {
if (currentRowIndex in pageRows.indices) {
listState.animateScrollToItem(currentRowIndex)
}
}
}
) {
Text("Locate")
}
}
HorizontalDivider()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxHeight()
.padding(end = 12.dp)
) {
items(pageRows, key = { it.firstOrNull() ?: 0 }) { row ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
row.forEach { pageIdx ->
Box(
modifier = Modifier
.weight(1f)
.aspectRatio(0.707f)
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp)
)
.border(
width = if (currentPage == pageIdx) 2.dp else 1.dp,
color = if (currentPage == pageIdx) MaterialTheme.colorScheme.primary else Color.Black.copy(alpha = 0.1f),
shape = RoundedCornerShape(4.dp)
)
.clickable {
onCloseDrawer()
onPageSelected(pageIdx)
},
contentAlignment = Alignment.Center
) {
var thumb by remember { mutableStateOf(PdfThumbnailCache.get(pageIdx)) }
LaunchedEffect(pageIdx, pdfDocument) {
if (thumb == null && pdfDocument != null) {
withContext(kotlinx.coroutines.Dispatchers.IO) {
try {
val cached = PdfThumbnailCache.get(pageIdx)
if (cached != null) {
thumb = cached
} else {
pdfDocument.openPage(pageIdx)?.use { p ->
val w = p.getPageWidthPoint()
val h = p.getPageHeightPoint()
val ratio = if (h > 0) w.toFloat() / h.toFloat() else 1f
val thumbW = 200
val thumbH = (thumbW / ratio).toInt().coerceAtLeast(1)
val bmp = createBitmap(thumbW, thumbH)
bmp.eraseColor(android.graphics.Color.WHITE)
p.renderPageBitmap(bmp, 0, 0, thumbW, thumbH, false)
PdfThumbnailCache.put(pageIdx, bmp)
thumb = bmp
}
}
} catch (_: Exception) { }
}
}
}
if (thumb != null) {
Image(
bitmap = thumb!!.asImageBitmap(),
contentDescription = "Page ${pageIdx + 1}",
modifier = Modifier.fillMaxSize()
)
}
Text(
text = "${pageIdx + 1}",
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.Bold
),
color = Color.White,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(4.dp)
.background(
Color.Black.copy(alpha = 0.5f),
RoundedCornerShape(6.dp)
)
.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
repeat(3 - row.size) { Spacer(modifier = Modifier.weight(1f)) }
}
}
}
VerticalScrollbar(
listState = listState,
modifier = Modifier.align(Alignment.CenterEnd)
)
}
}
}
} }
} }
} }

View file

@ -239,15 +239,45 @@ internal fun BookmarkButton(
} }
@Composable @Composable
internal fun ZoomPercentageIndicator(percentage: Int) { internal fun ZoomPercentageIndicator(
percentage: Int,
onResetZoomClick: () -> Unit
) {
Surface( Surface(
shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f) shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
) {
androidx.compose.foundation.layout.Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
) { ) {
Text( Text(
text = "$percentage%", text = "$percentage%",
color = Color.White, color = Color.White,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) )
Spacer(modifier = Modifier.width(8.dp))
// Divider
Box(
modifier = Modifier
.width(1.dp)
.height(16.dp)
.background(Color.White.copy(alpha = 0.5f))
)
Spacer(modifier = Modifier.width(8.dp))
// Reset Zoom Button
Icon(
painter = painterResource(id = R.drawable.zoom_out),
contentDescription = "Reset Zoom",
tint = Color.White,
modifier = Modifier
.size(20.dp)
.clip(RoundedCornerShape(4.dp))
.clickable(onClick = onResetZoomClick)
) )
} }
}
} }

View file

@ -13,6 +13,7 @@ import android.graphics.RectF
import android.graphics.Shader import android.graphics.Shader
import android.util.LruCache import android.util.LruCache
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
@ -83,6 +84,9 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.changedToUp import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.isTertiaryPressed
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker
@ -115,6 +119,7 @@ import androidx.core.graphics.scale
import androidx.core.graphics.set import androidx.core.graphics.set
import com.aryan.reader.R import com.aryan.reader.R
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.data.VirtualPage
@ -187,6 +192,78 @@ data class PageLink(
val source: LinkSource val source: LinkSource
) )
private data class ExpandedBubbleRender(
val bitmap: Bitmap,
val zoomFactor: Float
)
private fun computeDynamicBubbleZoomFactor(
bubbleBounds: RectF,
viewportWidth: Float,
viewportHeight: Float
): Float {
if (bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) return 1.5f
val targetWidth = viewportWidth * 0.6f
val targetHeight = viewportHeight * 0.32f
return min(targetWidth / bubbleBounds.width(), targetHeight / bubbleBounds.height())
.coerceIn(1.35f, 4.25f)
}
private fun isTapInsideBubble(
bubble: SpeechBubble,
tapX: Float,
tapY: Float,
hitSlopPx: Float
): Boolean {
val expandedBounds = RectF(bubble.bounds)
expandedBounds.inset(-hitSlopPx, -hitSlopPx)
if (!expandedBounds.contains(tapX, tapY)) return false
val mask = bubble.maskBitmap ?: return true
if (!bubble.bounds.contains(tapX, tapY)) return true
val normalizedX = ((tapX - bubble.bounds.left) / bubble.bounds.width()).coerceIn(0f, 0.999f)
val normalizedY = ((tapY - bubble.bounds.top) / bubble.bounds.height()).coerceIn(0f, 0.999f)
val maskX = (normalizedX * mask.width).toInt().coerceIn(0, mask.width - 1)
val maskY = (normalizedY * mask.height).toInt().coerceIn(0, mask.height - 1)
return AndroidColor.alpha(mask.getPixel(maskX, maskY)) > 24
}
private suspend fun renderExpandedBubbleBitmap(
document: ReaderDocument,
pageIndex: Int,
bubbleBounds: RectF,
pageWidth: Int,
pageHeight: Int,
renderScale: Float
): Bitmap? = withContext(Dispatchers.IO) {
if (pageWidth <= 0 || pageHeight <= 0 || bubbleBounds.width() <= 0f || bubbleBounds.height() <= 0f) {
return@withContext null
}
document.openPage(pageIndex)?.use { page ->
val cropWidth = (bubbleBounds.width() * renderScale).roundToInt().coerceAtLeast(1)
val cropHeight = (bubbleBounds.height() * renderScale).roundToInt().coerceAtLeast(1)
val bitmap = createBitmap(cropWidth, cropHeight)
try {
page.renderPageBitmap(
bitmap = bitmap,
startX = (-bubbleBounds.left * renderScale).roundToInt(),
startY = (-bubbleBounds.top * renderScale).roundToInt(),
drawSizeX = (pageWidth * renderScale).roundToInt().coerceAtLeast(cropWidth),
drawSizeY = (pageHeight * renderScale).roundToInt().coerceAtLeast(cropHeight),
renderAnnot = true
)
bitmap
} catch (t: Throwable) {
bitmap.recycle()
Timber.tag("BubbleZoom").w(t, "Failed to render expanded bubble bitmap for page $pageIndex")
null
}
}
}
object PdfInkGeometry { object PdfInkGeometry {
fun calculateFountainPenPoints( fun calculateFountainPenPoints(
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
@ -394,7 +471,8 @@ internal fun PdfPageComposable(
searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL, searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
searchResultToHighlight: SearchResult?, searchResultToHighlight: SearchResult?,
ocrHoverHighlights: StableHolder<List<RectF>> = StableHolder(emptyList()), ocrHoverHighlights: StableHolder<List<RectF>> = StableHolder(emptyList()),
onSingleTap: () -> Unit, onPreSingleTap: ((Offset) -> Boolean)? = null,
onSingleTap: (Offset?) -> Unit,
isProUser: Boolean, isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit, onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit, onWordSelectedForAiDefinition: (String) -> Unit,
@ -415,6 +493,7 @@ internal fun PdfPageComposable(
isVerticalScroll: Boolean = false, isVerticalScroll: Boolean = false,
visualScaleProvider: () -> Float = { 1f }, visualScaleProvider: () -> Float = { 1f },
clearSelectionTrigger: Long = 0L, clearSelectionTrigger: Long = 0L,
resetZoomTrigger: Long = 0L,
onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null, onTtsHighlightCenterCalculated: ((Float) -> Unit)? = null,
onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null, onSearchHighlightCenterCalculated: ((Float) -> Unit)? = null,
activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false), activeTheme: com.aryan.reader.ReaderTheme = com.aryan.reader.ReaderTheme("no_theme", "No Theme", Color.Unspecified, Color.Unspecified, false),
@ -423,8 +502,8 @@ internal fun PdfPageComposable(
isEditMode: Boolean = false, isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null, drawingState: PdfDrawingState? = null,
pageAnnotations: () -> List<PdfAnnotation> = { emptyList() }, pageAnnotations: () -> List<PdfAnnotation> = { emptyList() },
onDrawStart: (PdfPoint) -> Unit = {}, onDrawStart: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDraw: (PdfPoint) -> Unit = {}, onDraw: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDrawEnd: () -> Unit = {}, onDrawEnd: () -> Unit = {},
visibleScreenRect: () -> IntRect? = { null }, visibleScreenRect: () -> IntRect? = { null },
selectedTool: InkType = InkType.PEN, selectedTool: InkType = InkType.PEN,
@ -441,6 +520,7 @@ internal fun PdfPageComposable(
isScrollLocked: Boolean = false, isScrollLocked: Boolean = false,
isVisible: Boolean = true, isVisible: Boolean = true,
isActivePage: Boolean = true, isActivePage: Boolean = true,
isBubbleZoomModeActive: Boolean = false,
isStylusOnlyMode: Boolean = false, isStylusOnlyMode: Boolean = false,
isAutoScrollPlaying: Boolean = false, isAutoScrollPlaying: Boolean = false,
isHighlighterSnapEnabled: Boolean = false, isHighlighterSnapEnabled: Boolean = false,
@ -455,7 +535,7 @@ internal fun PdfPageComposable(
onPaletteClick: (() -> Unit)? = null, onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null, lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null, onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() }, onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() },
onShowPanelPopup: (Bitmap) -> Unit = {} onShowPanelPopup: (Bitmap) -> Unit = {}
) { ) {
val pdfDocumentItem = pdfDocument.item val pdfDocumentItem = pdfDocument.item
@ -475,10 +555,7 @@ internal fun PdfPageComposable(
LocalContext.current LocalContext.current
val viewConfiguration = LocalViewConfiguration.current val viewConfiguration = LocalViewConfiguration.current
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var isStylusEraserOverride by remember { mutableStateOf(false) }
Timber.d(
"PdfPageComposable recompose: page=$pageIndex, isScrolling=$isScrolling, visualScale=$visualScaleProvider"
)
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) } var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
@ -493,6 +570,7 @@ internal fun PdfPageComposable(
} }
val currentOnSingleTap by rememberUpdatedState(onSingleTap) val currentOnSingleTap by rememberUpdatedState(onSingleTap)
val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap)
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap) val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
@ -618,9 +696,7 @@ internal fun PdfPageComposable(
} }
LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) { LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) {
Timber.d(
"PdfPageComposable Page $pageIndex | Centering Offset: x=$centeringOffsetX, y=$centeringOffsetY"
)
} }
var showMagnifier by remember { mutableStateOf(false) } var showMagnifier by remember { mutableStateOf(false) }
@ -646,6 +722,132 @@ internal fun PdfPageComposable(
screenOffset screenOffset
} }
var detectedBubbles by remember(targetPageId) { mutableStateOf<List<SpeechBubble>>(emptyList()) }
var expandedBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
var animatingBubbleIndex by remember(targetPageId) { mutableIntStateOf(-1) }
val bubbleExpansionProgress = remember(targetPageId) { Animatable(0f) }
var isDetectingBubbles by remember(targetPageId) { mutableStateOf(false) }
var expandedBubbleRender by remember(targetPageId) { mutableStateOf<ExpandedBubbleRender?>(null) }
val currentDetectedBubbles by rememberUpdatedState(detectedBubbles)
val currentExpandedBubbleIndex by rememberUpdatedState(expandedBubbleIndex)
val currentBubbleZoomModeActive by rememberUpdatedState(isBubbleZoomModeActive)
val bubbleTapSlopPx = with(density) { 18.dp.toPx() }
LaunchedEffect(expandedBubbleIndex) {
if (expandedBubbleIndex != -1) {
if (animatingBubbleIndex != -1 && animatingBubbleIndex != expandedBubbleIndex) {
bubbleExpansionProgress.animateTo(0f, tween(150))
}
animatingBubbleIndex = expandedBubbleIndex
bubbleExpansionProgress.animateTo(1f, tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing))
} else {
bubbleExpansionProgress.animateTo(0f, tween(200, easing = androidx.compose.animation.core.FastOutLinearInEasing))
animatingBubbleIndex = -1
}
}
LaunchedEffect(
isBubbleZoomModeActive,
isActivePage,
isPdfPage,
pdfPageIndex,
bitmapState,
actualBitmapWidthPx,
actualBitmapHeightPx
) {
Timber.tag("BubbleZoom").d("LaunchedEffect triggered. modeActive=$isBubbleZoomModeActive, activePage=$isActivePage, hasBitmap=${bitmapState != null}, dims=${actualBitmapWidthPx}x${actualBitmapHeightPx}")
if (isBubbleZoomModeActive && isActivePage && isPdfPage && bitmapState != null && actualBitmapWidthPx > 0 && actualBitmapHeightPx > 0) {
Timber.tag("BubbleZoom").d("Conditions met. Starting detection...")
isDetectingBubbles = true
try {
val rawBubbles = onDetectBubbles(pdfPageIndex, bitmapState!!)
Timber.tag("BubbleZoom").d("Detection complete. Found ${rawBubbles.size} raw bubbles.")
// NEW: Scale bubbles down from render bitmap space to logical screen space
val scaleX = actualBitmapWidthPx.toFloat() / bitmapState!!.width.toFloat()
val scaleY = actualBitmapHeightPx.toFloat() / bitmapState!!.height.toFloat()
val logicalBubbles = rawBubbles.map { b ->
b.copy(bounds = android.graphics.RectF(
b.bounds.left * scaleX,
b.bounds.top * scaleY,
b.bounds.right * scaleX,
b.bounds.bottom * scaleY
))
}
val rowHeight = actualBitmapHeightPx * 0.1f
detectedBubbles = logicalBubbles.sortedWith(compareBy<SpeechBubble> { (it.bounds.centerY() / rowHeight).roundToInt() }.thenBy { it.bounds.centerX() })
expandedBubbleIndex = -1
Timber.tag("BubbleZoom").d("Sorted logical bubbles count: ${detectedBubbles.size}")
} catch (e: Exception) {
Timber.tag("BubbleZoom").e(e, "Bubble detection failed with exception")
} finally {
isDetectingBubbles = false
}
} else {
Timber.tag("BubbleZoom").d("Conditions NOT met or mode disabled. Clearing bubbles.")
detectedBubbles = emptyList()
expandedBubbleIndex = -1
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
expandedBubbleRender = null
if (!isBubbleZoomModeActive && scale > 1f && !isVerticalScroll && isZoomEnabled) {
coroutineScope.launch {
Animatable(scale).animateTo(1f, tween(300)) {
scale = this.value
offset = Offset.Zero
onScaleChanged(scale)
}
}
}
}
}
LaunchedEffect(
animatingBubbleIndex,
detectedBubbles,
actualBitmapWidthPx,
actualBitmapHeightPx,
canvasWidthPx.floatValue,
canvasHeightPx.floatValue,
isBubbleZoomModeActive,
isPdfPage,
pdfPageIndex
) {
val previousRender = expandedBubbleRender
expandedBubbleRender = null
previousRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
if (!isBubbleZoomModeActive || !isPdfPage || animatingBubbleIndex !in detectedBubbles.indices) {
return@LaunchedEffect
}
val bubble = detectedBubbles[animatingBubbleIndex]
val zoomFactor = computeDynamicBubbleZoomFactor(
bubbleBounds = bubble.bounds,
viewportWidth = canvasWidthPx.floatValue.coerceAtLeast(actualBitmapWidthPx.toFloat()),
viewportHeight = canvasHeightPx.floatValue.coerceAtLeast(actualBitmapHeightPx.toFloat())
)
val renderScale = (zoomFactor * 1.2f).coerceAtLeast(1.6f)
val renderedBubble = renderExpandedBubbleBitmap(
document = pdfDocumentItem,
pageIndex = pdfPageIndex,
bubbleBounds = bubble.bounds,
pageWidth = actualBitmapWidthPx,
pageHeight = actualBitmapHeightPx,
renderScale = renderScale
)
if (renderedBubble != null) {
expandedBubbleRender = ExpandedBubbleRender(
bitmap = renderedBubble,
zoomFactor = zoomFactor
)
}
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
onDispose { onDispose {
val currentBitmap = bitmapState val currentBitmap = bitmapState
@ -653,6 +855,7 @@ internal fun PdfPageComposable(
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) { if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
currentBitmap.recycle() currentBitmap.recycle()
} }
expandedBubbleRender?.bitmap?.takeUnless { it.isRecycled }?.recycle()
} }
} }
@ -1646,6 +1849,32 @@ internal fun PdfPageComposable(
} }
} }
LaunchedEffect(resetZoomTrigger) {
if (resetZoomTrigger != 0L && scale > 1f && isZoomEnabled && !isVerticalScroll && !isScrollLocked) {
coroutineScope.launch {
val startScale = scale
val startOffset = offset
Animatable(0f).animateTo(
1f, animationSpec = tween(durationMillis = 300)
) {
val progress = value
scale = androidx.compose.ui.util.lerp(
startScale, 1f, progress
)
offset = androidx.compose.ui.geometry.lerp(
startOffset, Offset.Zero, progress
)
onScaleChanged(scale)
}
if (scale <= 1.05f) {
scale = 1f
offset = Offset.Zero
onScaleChanged(scale)
}
}
}
}
val errorSelection = stringResource(R.string.error_selection) val errorSelection = stringResource(R.string.error_selection)
val errorOcrSelection = stringResource(R.string.error_ocr_selection) val errorOcrSelection = stringResource(R.string.error_ocr_selection)
val errorProcessingPage = stringResource(R.string.error_processing_page) val errorProcessingPage = stringResource(R.string.error_processing_page)
@ -2487,7 +2716,8 @@ internal fun PdfPageComposable(
isEditMode, isEditMode,
selectedTool, selectedTool,
isStylusOnlyMode, isStylusOnlyMode,
userHighlightScreenRects userHighlightScreenRects,
bubbleTapSlopPx
) { ) {
val isTapDetectionAllowed = !isEditMode || val isTapDetectionAllowed = !isEditMode ||
selectedTool == InkType.TEXT || selectedTool == InkType.TEXT ||
@ -2496,9 +2726,48 @@ internal fun PdfPageComposable(
if (!isTapDetectionAllowed) return@pointerInput if (!isTapDetectionAllowed) return@pointerInput
detectTapGestures(onTap = { tapOffset -> detectTapGestures(onTap = { tapOffset ->
if (currentOnPreSingleTap?.invoke(tapOffset) == true) {
return@detectTapGestures
}
val tapInContentCoords = screenToContentCoordinates(tapOffset) val tapInContentCoords = screenToContentCoordinates(tapOffset)
val tapXInBitmap = tapInContentCoords.x val tapXInBitmap = tapInContentCoords.x
val tapYInBitmap = tapInContentCoords.y val tapYInBitmap = tapInContentCoords.y
val isWithinContentBounds =
tapXInBitmap in 0f..actualBitmapWidthPx.toFloat() &&
tapYInBitmap in 0f..actualBitmapHeightPx.toFloat()
if (!isWithinContentBounds) {
currentOnSingleTap(tapOffset)
return@detectTapGestures
}
Timber.tag("BubbleZoom").d("Tap inside bounds. modeActive=$currentBubbleZoomModeActive, detectedBubbles=${currentDetectedBubbles.size}, tapPos=($tapXInBitmap, $tapYInBitmap)")
if (currentBubbleZoomModeActive && currentDetectedBubbles.isNotEmpty()) {
val tappedBubbleIndex = currentDetectedBubbles.indexOfFirst { bubble ->
isTapInsideBubble(
bubble = bubble,
tapX = tapXInBitmap,
tapY = tapYInBitmap,
hitSlopPx = bubbleTapSlopPx
)
}
Timber.tag("BubbleZoom").d("Tapped bubble index: $tappedBubbleIndex (expandedIndex=$currentExpandedBubbleIndex)")
if (tappedBubbleIndex != -1) {
expandedBubbleIndex = if (currentExpandedBubbleIndex == tappedBubbleIndex) {
-1
} else {
tappedBubbleIndex
}
return@detectTapGestures
} else if (currentExpandedBubbleIndex != -1) {
expandedBubbleIndex = -1
return@detectTapGestures
}
}
coroutineScope.launch { coroutineScope.launch {
val nativeResult = withContext(Dispatchers.IO) { val nativeResult = withContext(Dispatchers.IO) {
@ -2660,7 +2929,7 @@ internal fun PdfPageComposable(
currentPageRotation, currentPageRotation,
) )
} else { } else {
currentOnSingleTap() currentOnSingleTap(tapOffset)
} }
} }
}, onDoubleTap = { tapOffset -> }, onDoubleTap = { tapOffset ->
@ -2670,37 +2939,6 @@ internal fun PdfPageComposable(
val startScale = scale val startScale = scale
val targetScale = if (startScale > 1.1f) 1f else 2.5f val targetScale = if (startScale > 1.1f) 1f else 2.5f
if (com.aryan.reader.BuildConfig.DEBUG && startScale <= 1.1f && bitmapState != null) {
val tapInContentCoords = screenToContentCoordinates(tapOffset)
val ratioX = bitmapState!!.width.toFloat() / actualBitmapWidthPx.toFloat()
val ratioY = bitmapState!!.height.toFloat() / actualBitmapHeightPx.toFloat()
val tapXInBitmap = tapInContentCoords.x * ratioX
val tapYInBitmap = tapInContentCoords.y * ratioY
val panels = onDetectPanels(bitmapState!!)
val tappedPanel = panels.firstOrNull {
it.contains(tapXInBitmap, tapYInBitmap)
}
if (tappedPanel != null) {
Timber.d("Popup: Cropping panel $tappedPanel")
val left = tappedPanel.left.coerceAtLeast(0f).toInt()
val top = tappedPanel.top.coerceAtLeast(0f).toInt()
val right = tappedPanel.right.coerceAtMost(bitmapState!!.width.toFloat()).toInt()
val bottom = tappedPanel.bottom.coerceAtMost(bitmapState!!.height.toFloat()).toInt()
val width = right - left
val height = bottom - top
if (width > 0 && height > 0) {
val cropped = android.graphics.Bitmap.createBitmap(bitmapState!!, left, top, width, height)
onShowPanelPopup(cropped)
return@launch
}
}
}
val startOffset = offset val startOffset = offset
val targetOffsetUnbounded = if (targetScale <= 1.1f) { val targetOffsetUnbounded = if (targetScale <= 1.1f) {
Offset.Zero Offset.Zero
@ -3017,12 +3255,20 @@ internal fun PdfPageComposable(
return@awaitEachGesture return@awaitEachGesture
} }
val buttons = currentEvent.buttons
Timber.tag("StylusEraserDiagnostic").d(
"Page $pageIndex | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
)
val isEraserOverride = down.type == PointerType.Eraser || (down.type == PointerType.Stylus && currentEvent.buttons.isSecondaryPressed)
isStylusEraserOverride = isEraserOverride
val dragPointerId = down.id val dragPointerId = down.id
val startPos = down.position val startPos = down.position
var dragStarted = false var dragStarted = false
val touchSlop = viewConfiguration.touchSlop val touchSlop = viewConfiguration.touchSlop
if (selectedTool == InkType.ERASER) { if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = down.position eraserPosition = down.position
} }
@ -3034,6 +3280,7 @@ internal fun PdfPageComposable(
drawingState?.onDrawCancel() drawingState?.onDrawCancel()
} }
eraserPosition = null eraserPosition = null
isStylusEraserOverride = false
return@awaitEachGesture return@awaitEachGesture
} }
@ -3051,12 +3298,13 @@ internal fun PdfPageComposable(
val normY = val normY =
(contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f) (contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
onDrawStart(PdfPoint(normX, normY)) onDrawStart(PdfPoint(normX, normY), isEraserOverride)
onDrawEnd() onDrawEnd()
} else { } else {
onDrawEnd() onDrawEnd()
} }
eraserPosition = null eraserPosition = null
isStylusEraserOverride = false
return@awaitEachGesture return@awaitEachGesture
} }
@ -3077,7 +3325,7 @@ internal fun PdfPageComposable(
0f, 1f 0f, 1f
) )
onDrawStart( onDrawStart(
PdfPoint(startNormX, startNormY) PdfPoint(startNormX, startNormY), isEraserOverride
) )
val currContentPos = screenToContentCoordinates( val currContentPos = screenToContentCoordinates(
@ -3091,9 +3339,9 @@ internal fun PdfPageComposable(
(currContentPos.y / actualBitmapHeightPx).coerceIn( (currContentPos.y / actualBitmapHeightPx).coerceIn(
0f, 1f 0f, 1f
) )
onDraw(PdfPoint(currNormX, currNormY)) onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
if (selectedTool == InkType.ERASER) { if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = change.position eraserPosition = change.position
} }
change.consume() change.consume()
@ -3106,9 +3354,9 @@ internal fun PdfPageComposable(
(currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f) (currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
val currNormY = val currNormY =
(currContentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f) (currContentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
onDraw(PdfPoint(currNormX, currNormY)) onDraw(PdfPoint(currNormX, currNormY), isEraserOverride)
if (selectedTool == InkType.ERASER) { if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = change.position eraserPosition = change.position
} }
change.consume() change.consume()
@ -3118,6 +3366,7 @@ internal fun PdfPageComposable(
} }
} finally { } finally {
eraserPosition = null eraserPosition = null
isStylusEraserOverride = false
} }
}, contentAlignment = Alignment.Center }, contentAlignment = Alignment.Center
) { ) {
@ -3230,10 +3479,6 @@ internal fun PdfPageComposable(
offset = Offset.Zero offset = Offset.Zero
onScaleChanged(1f) onScaleChanged(1f)
} }
Timber.d(
"PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset"
)
} }
LaunchedEffect( LaunchedEffect(
@ -3386,15 +3631,8 @@ internal fun PdfPageComposable(
val viewContainerHeightPx = val viewContainerHeightPx =
with(density) { currentContainerMaxHeight.toPx().toInt() } with(density) { currentContainerMaxHeight.toPx().toInt() }
Timber.d(
"PdfPageComposable Page $pageIndex | viewContainerPx: ${viewContainerWidthPx}x${viewContainerHeightPx}"
)
if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) { if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) {
if (bitmapState == null) isLoadingPage = true if (bitmapState == null) isLoadingPage = true
Timber.d(
"PdfPageComposable: viewContainer dimensions invalid ($viewContainerWidthPx x $viewContainerHeightPx), waiting."
)
return@LaunchedEffect return@LaunchedEffect
} }
@ -3948,6 +4186,7 @@ internal fun PdfPageComposable(
isEditMode = isEditMode, isEditMode = isEditMode,
selectedTool = selectedTool, selectedTool = selectedTool,
eraserPosition = eraserPosition, eraserPosition = eraserPosition,
isStylusEraserOverride = isStylusEraserOverride,
activeToolThickness = activeToolThickness, activeToolThickness = activeToolThickness,
richTextController = richTextController, richTextController = richTextController,
textBoxes = textBoxes, textBoxes = textBoxes,
@ -3960,7 +4199,14 @@ internal fun PdfPageComposable(
onDragPageTurn = onDragPageTurn, onDragPageTurn = onDragPageTurn,
draggingBoxId = draggingBoxId, draggingBoxId = draggingBoxId,
customHighlightColors = customHighlightColors, customHighlightColors = customHighlightColors,
onPaletteClick = onPaletteClick onPaletteClick = onPaletteClick,
isBubbleZoomModeActive = isBubbleZoomModeActive,
isActivePage = isActivePage,
isDetectingBubbles = isDetectingBubbles,
detectedBubbles = detectedBubbles,
animatingBubbleIndex = animatingBubbleIndex,
bubbleExpansionProgress = bubbleExpansionProgress.value,
expandedBubbleRender = expandedBubbleRender
) )
} }
@ -4080,15 +4326,10 @@ private fun PdfBitmapLayer(
if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) { if (excludeImages && colorFilter != null && imageRects.isNotEmpty()) {
imageRects.forEach { imgRect -> imageRects.forEach { imgRect ->
val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt() val intersectLeft = max(imgRect.left, tile.renderRect.left)
val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt() val intersectTop = max(imgRect.top, tile.renderRect.top)
val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt() val intersectRight = min(imgRect.right, tile.renderRect.right)
val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt() val intersectBottom = min(imgRect.bottom, tile.renderRect.bottom)
val intersectLeft = max(scaledImgRectLeft, tile.renderRect.left)
val intersectTop = max(scaledImgRectTop, tile.renderRect.top)
val intersectRight = min(scaledImgRectRight, tile.renderRect.right)
val intersectBottom = min(scaledImgRectBottom, tile.renderRect.bottom)
val iw = intersectRight - intersectLeft val iw = intersectRight - intersectLeft
val ih = intersectBottom - intersectTop val ih = intersectBottom - intersectTop
@ -4151,7 +4392,6 @@ private fun PdfHighlightsLayer(
selectionHighlightColor: Color, selectionHighlightColor: Color,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap() customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap()
) { ) {
Timber.d("PdfHighlightsLayer Recompose")
Canvas(modifier = Modifier Canvas(modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer()) { .graphicsLayer()) {
@ -4751,6 +4991,7 @@ private fun PdfPageRenderer(
isEditMode: Boolean, isEditMode: Boolean,
selectedTool: InkType, selectedTool: InkType,
eraserPosition: Offset?, eraserPosition: Offset?,
isStylusEraserOverride: Boolean,
richTextController: RichTextController?, richTextController: RichTextController?,
textBoxes: List<PdfTextBox>, textBoxes: List<PdfTextBox>,
selectedTextBoxId: String?, selectedTextBoxId: String?,
@ -4769,6 +5010,13 @@ private fun PdfPageRenderer(
onTts: (Int, Int) -> Unit, onTts: (Int, Int) -> Unit,
activeToolThickness: Float, activeToolThickness: Float,
onNote: (String?) -> Unit, onNote: (String?) -> Unit,
isBubbleZoomModeActive: Boolean = false,
isActivePage: Boolean = true,
isDetectingBubbles: Boolean = false,
detectedBubbles: List<SpeechBubble> = emptyList(),
animatingBubbleIndex: Int = -1,
bubbleExpansionProgress: Float = 0f,
expandedBubbleRender: ExpandedBubbleRender? = null
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
Box( Box(
@ -4976,7 +5224,7 @@ private fun PdfPageRenderer(
val teardropPainter = painterResource(id = R.drawable.teardrop) val teardropPainter = painterResource(id = R.drawable.teardrop)
if (isEditMode && selectedTool == InkType.ERASER && eraserPosition != null) { if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && eraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) { val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
@ -5203,6 +5451,150 @@ private fun PdfPageRenderer(
if (isPerformingOcr && ocrRipplePos != null) { if (isPerformingOcr && ocrRipplePos != null) {
OcrProcessingIndicator(position = ocrRipplePos) OcrProcessingIndicator(position = ocrRipplePos)
} }
if (isBubbleZoomModeActive && isActivePage) {
if (isDetectingBubbles) {
androidx.compose.material3.CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
} else if (detectedBubbles.isNotEmpty()) {
Canvas(modifier = Modifier.fillMaxSize().zIndex(20f)) {
// Draw shadow-like hints for unexpanded bubbles
detectedBubbles.forEachIndexed { index, bubble ->
val hintAlpha = if (index == animatingBubbleIndex) 0.35f * (1f - bubbleExpansionProgress) else 0.35f
if (hintAlpha > 0f) {
val left = bubble.bounds.left + staticData.centeringOffsetX
val top = bubble.bounds.top + staticData.centeringOffsetY
val width = bubble.bounds.width()
val height = bubble.bounds.height()
if (bubble.maskBitmap != null) {
drawImage(
image = bubble.maskBitmap.asImageBitmap(),
dstOffset = IntOffset(left.toInt(), top.toInt()),
dstSize = IntSize(width.toInt(), height.toInt()),
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = hintAlpha)),
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
} else {
drawRoundRect(
color = Color.Black.copy(alpha = hintAlpha),
topLeft = Offset(left, top),
size = Size(width, height),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
)
}
}
}
if (animatingBubbleIndex in detectedBubbles.indices && staticData.bitmap.item != null && bubbleExpansionProgress > 0f) {
val bubble = detectedBubbles[animatingBubbleIndex]
val left = bubble.bounds.left + staticData.centeringOffsetX
val top = bubble.bounds.top + staticData.centeringOffsetY
val logicalWidth = bubble.bounds.width()
val logicalHeight = bubble.bounds.height()
val pivotX = left + logicalWidth / 2f
val pivotY = top + logicalHeight / 2f
val targetZoomFactor = expandedBubbleRender?.zoomFactor ?: computeDynamicBubbleZoomFactor(
bubbleBounds = bubble.bounds,
viewportWidth = staticData.canvasWidth,
viewportHeight = staticData.canvasHeight
)
val zoomFactor = androidx.compose.ui.util.lerp(1f, targetZoomFactor, bubbleExpansionProgress)
withTransform({
scale(zoomFactor, zoomFactor, Offset(pivotX, pivotY))
}) {
val dstOffset = IntOffset(left.toInt(), top.toInt())
val dstSize = IntSize(logicalWidth.toInt(), logicalHeight.toInt())
val renderScaleX = staticData.bitmap.item.width.toFloat() / staticData.targetWidth.toFloat()
val renderScaleY = staticData.bitmap.item.height.toFloat() / staticData.targetHeight.toFloat()
val srcOffset = IntOffset(
(bubble.bounds.left * renderScaleX).toInt(),
(bubble.bounds.top * renderScaleY).toInt()
)
val srcSize = IntSize(
(logicalWidth * renderScaleX).toInt(),
(logicalHeight * renderScaleY).toInt()
)
if (bubble.maskBitmap != null) {
drawImage(
image = bubble.maskBitmap.asImageBitmap(),
dstOffset = IntOffset(left.toInt() + 12, top.toInt() + 12),
dstSize = dstSize,
colorFilter = ColorFilter.tint(Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress)),
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
} else {
drawRoundRect(
color = Color.Black.copy(alpha = 0.5f * bubbleExpansionProgress),
topLeft = Offset(left + 12f, top + 12f),
size = Size(logicalWidth, logicalHeight),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(24f, 24f)
)
}
if (bubble.maskBitmap != null) {
val rect = androidx.compose.ui.geometry.Rect(
dstOffset.x.toFloat(),
dstOffset.y.toFloat(),
dstOffset.x.toFloat() + dstSize.width,
dstOffset.y.toFloat() + dstSize.height
)
drawContext.canvas.saveLayer(rect, androidx.compose.ui.graphics.Paint())
drawImage(
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) {
IntSize(
expandedBubbleRender.bitmap.width,
expandedBubbleRender.bitmap.height)
} else {
srcSize
},
dstOffset = dstOffset,
dstSize = dstSize,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
drawImage(
image = bubble.maskBitmap.asImageBitmap(),
dstOffset = dstOffset,
dstSize = dstSize,
blendMode = BlendMode.DstIn,
filterQuality = androidx.compose.ui.graphics.FilterQuality.High
)
drawContext.canvas.restore()
} else {
clipRect(left, top, left + logicalWidth, top + logicalHeight) {
drawImage(
image = (expandedBubbleRender?.bitmap ?: staticData.bitmap.item).asImageBitmap(),
srcOffset = if (expandedBubbleRender != null) IntOffset.Zero else srcOffset,
srcSize = if (expandedBubbleRender != null) {
IntSize(
expandedBubbleRender.bitmap.width,
expandedBubbleRender.bitmap.height)
} else {
srcSize
},
dstOffset = dstOffset,
dstSize = dstSize
)
}
drawRect(
color = Color.White.copy(alpha = 0.5f * bubbleExpansionProgress),
topLeft = Offset(left, top),
size = Size(logicalWidth, logicalHeight),
style = Stroke(width = 4f)
)
}
}
}
}
}
}
} }
} }

View file

@ -47,6 +47,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
THEME("Theme Settings", "Top Bar"), THEME("Theme Settings", "Top Bar"),
LOCK_PANNING("Lock Panning", "Top Bar"), LOCK_PANNING("Lock Panning", "Top Bar"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"), VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
FULL_SCREEN("Full Screen", "Top Bar"), FULL_SCREEN("Full Screen", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"), SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"), TOC("Sidebar", "Bottom Bar"),

View file

@ -1,229 +0,0 @@
package com.aryan.reader.pdf
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import org.json.JSONArray
import timber.log.Timber
private const val MAX_FIXED_RECURSION = 128
internal data class PdfBookmark(val pageIndex: Int, val title: String, val totalPages: Int)
internal data class TocEntry(val title: String, val pageIndex: Int, val nestLevel: Int)
/**
* Patches the library bug where siblings are truncated due to depth-state leakage.
*/
suspend fun PdfDocumentKt.getFixedTableOfContents(): List<Bookmark> {
val tag = "PdfTocFix"
Timber.tag(tag).i("Starting Pure Reflection Traversal...")
return try {
// 1. Get the 'document' field (PdfDocumentU) from PdfDocumentKt
val documentField = PdfDocumentKt::class.java.getDeclaredField("document").apply { isAccessible = true }
val docUInstance = documentField.get(this) ?: return getTableOfContents()
// 2. Get the 'nativeDocument' field from PdfDocumentU
val nativeDocField = docUInstance.javaClass.getDeclaredField("nativeDocument").apply { isAccessible = true }
val nativeDocInstance = nativeDocField.get(docUInstance) ?: return getTableOfContents()
// 3. Get the native pointer (long) from PdfDocumentU
val ptrField = docUInstance.javaClass.getDeclaredField("mNativeDocPtr").apply { isAccessible = true }
val mNativeDocPtr = ptrField.get(docUInstance) as Long
// 4. Look up native methods using primitive 'long' types (mandatory for JNI)
val nClass = nativeDocInstance.javaClass
val lp = Long::class.javaPrimitiveType!! // Shorthand for 'long'
val getTitleM = nClass.getMethod("getBookmarkTitle", lp)
val getDestIdxM = nClass.getMethod("getBookmarkDestIndex", lp, lp)
val getFirstChildM = nClass.getMethod("getFirstChildBookmark", lp, lp)
val getSiblingM = nClass.getMethod("getSiblingBookmark", lp, lp)
val topLevel = mutableListOf<Bookmark>()
val visited = mutableSetOf<Long>()
/**
* Corrected traversal: Iterative for siblings, recursive for children.
*/
fun walk(parentList: MutableList<Bookmark>, startPtr: Long, level: Int) {
var currentPtr = startPtr
var itemIndex = 0
while (currentPtr != 0L) {
if (visited.contains(currentPtr)) break
visited.add(currentPtr)
val title = getTitleM.invoke(nativeDocInstance, currentPtr) as? String ?: "Untitled"
val pageIdx = getDestIdxM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
Timber.tag(tag).v("Lvl $level | Item $itemIndex | Ptr: 0x${java.lang.Long.toHexString(currentPtr)} | $title")
val bookmark = Bookmark().apply {
this.mNativePtr = currentPtr
this.title = title
this.pageIdx = pageIdx
}
parentList.add(bookmark)
// Recursive dive into children
val firstChild = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
if (firstChild != 0L && level < MAX_FIXED_RECURSION) {
walk(bookmark.children, firstChild, level + 1)
}
// Iterative move to next sibling
currentPtr = getSiblingM.invoke(nativeDocInstance, mNativeDocPtr, currentPtr) as Long
itemIndex++
}
}
// 5. Start from the root (Pass 0L as primitive long)
val firstRoot = getFirstChildM.invoke(nativeDocInstance, mNativeDocPtr, 0L) as Long
if (firstRoot != 0L) {
walk(topLevel, firstRoot, 0)
}
if (topLevel.isEmpty()) {
Timber.tag(tag).w("No items found, falling back to library.")
getTableOfContents()
} else {
Timber.tag(tag).i("TOC Successfully Patched! Nodes: ${visited.size}")
topLevel
}
} catch (e: Exception) {
Timber.tag(tag).e(e, "Reflection traversal critical error.")
this.getTableOfContents()
}
}
internal fun flattenToc(bookmarks: List<Bookmark>, level: Int = 0): List<TocEntry> {
Timber.tag("PdfTocDebug").d("Processing level $level with ${bookmarks.size} items")
val entries = mutableListOf<TocEntry>()
for ((index, bookmark) in bookmarks.withIndex()) {
val title = bookmark.title ?: "Untitled Chapter"
val childCount = bookmark.children.size
Timber.tag("PdfTocDebug").d(
"Lvl $level | Item $index: \"$title\" (Page: ${bookmark.pageIdx}) | Children: $childCount"
)
entries.add(
TocEntry(
title = title,
pageIndex = bookmark.pageIdx.toInt(),
nestLevel = level
)
)
if (childCount > 0) {
Timber.tag("PdfTocDebug").v("Entering children of \"$title\"")
entries.addAll(flattenToc(bookmark.children, level + 1))
Timber.tag("PdfTocDebug").v("Returned to Lvl $level from \"$title\"")
}
}
return entries
}
internal fun loadPdfBookmarksFromJson(bookmarksJson: String?): Set<PdfBookmark> {
if (bookmarksJson.isNullOrBlank()) return emptySet()
return try {
val jsonArray = JSONArray(bookmarksJson)
(0 until jsonArray.length()).mapNotNull { i ->
try {
val json = jsonArray.getJSONObject(i)
PdfBookmark(
pageIndex = json.getInt("pageIndex"),
title = json.getString("title"),
totalPages = json.getInt("totalPages")
)
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmark from JSON object")
null
}
}.toSet()
} catch (e: Exception) {
Timber.e(e, "Failed to parse bookmarks from JSON string: $bookmarksJson")
emptySet()
}
}
@Composable
internal fun PdfTocTreeItem(
label: String,
nestLevel: 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 = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width((16 * nestLevel).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 (nestLevel == 0) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.bodyMedium,
fontWeight = if (isCurrent) FontWeight.Bold else if (nestLevel == 0) FontWeight.SemiBold else FontWeight.Normal,
color = contentColor,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f).padding(end = 16.dp)
)
}
}

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@ -33,6 +34,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.sp
import com.aryan.reader.BuildConfig import com.aryan.reader.BuildConfig
import com.aryan.reader.FileType import com.aryan.reader.FileType
import com.aryan.reader.R import com.aryan.reader.R
@ -81,6 +83,8 @@ internal fun PdfTopBar(
onShowCustomizeTools: () -> Unit, onShowCustomizeTools: () -> Unit,
onShowOcrLanguage: () -> Unit, onShowOcrLanguage: () -> Unit,
onShowVisualOptions: () -> Unit, onShowVisualOptions: () -> Unit,
tapToNavigateEnabled: Boolean,
onToggleTapToNavigate: () -> Unit,
onChangeDisplayMode: (DisplayMode) -> Unit, onChangeDisplayMode: (DisplayMode) -> Unit,
onToggleKeepScreenOn: () -> Unit, onToggleKeepScreenOn: () -> Unit,
onStartAutoScroll: () -> Unit, onStartAutoScroll: () -> Unit,
@ -94,7 +98,8 @@ internal fun PdfTopBar(
onPrint: () -> Unit, onPrint: () -> Unit,
onTabClick: (String) -> Unit, onTabClick: (String) -> Unit,
onTabClose: (String) -> Unit, onTabClose: (String) -> Unit,
onNewTabClick: () -> Unit onNewTabClick: () -> Unit,
onGenerateDemoAnnotations: () -> Unit
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = showStandardBars, visible = showStandardBars,
@ -179,6 +184,9 @@ internal fun PdfTopBar(
} }
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
TooltipIconButton(text = "Demo Annotations", onClick = onGenerateDemoAnnotations) {
Icon(Icons.Default.BugReport, contentDescription = "Generate Demo Annotations", tint = MaterialTheme.colorScheme.secondary)
}
TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) { TooltipIconButton(text = stringResource(R.string.pen_playground), onClick = onShowPenPlayground) {
Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary) Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary)
} }
@ -238,6 +246,26 @@ internal fun PdfTopBar(
HorizontalDivider() HorizontalDivider()
} }
if (!hiddenTools.contains(PdfReaderTool.TAP_TO_TURN.name)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tap_to_turn_pages)) },
enabled = displayMode == DisplayMode.PAGINATION,
onClick = {
onToggleTapToNavigate()
showMoreMenu = false
},
trailingIcon = {
if (tapToNavigateEnabled) {
Icon(
Icons.Filled.Check,
contentDescription = stringResource(R.string.content_desc_enabled)
)
}
}
)
HorizontalDivider()
}
if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) { if (!hiddenTools.contains(PdfReaderTool.KEEP_SCREEN_ON.name)) {
DropdownMenuItem( DropdownMenuItem(
text = { Text(stringResource(R.string.menu_keep_screen_on)) }, text = { Text(stringResource(R.string.menu_keep_screen_on)) },
@ -433,13 +461,17 @@ fun PdfBottomBar(
isEditMode: Boolean, isEditMode: Boolean,
isTtsSessionActive: Boolean, isTtsSessionActive: Boolean,
ttsErrorMessage: String?, ttsErrorMessage: String?,
jumpBackPage: Int?,
onJumpBack: () -> Unit,
onShowSlider: () -> Unit, onShowSlider: () -> Unit,
onShowToc: () -> Unit, onShowToc: () -> Unit,
onSearchClick: () -> Unit, onSearchClick: () -> Unit,
onToggleHighlights: () -> Unit, onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit, onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit, onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit onToggleTts: () -> Unit,
isBubbleZoomModeActive: Boolean,
onToggleBubbleZoom: () -> Unit
) { ) {
AnimatedVisibility( AnimatedVisibility(
visible = showStandardBars && !searchStateActive, visible = showStandardBars && !searchStateActive,
@ -456,8 +488,35 @@ fun PdfBottomBar(
Row( Row(
modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState), modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp) horizontalArrangement = Arrangement.SpaceEvenly
) { ) {
if (jumpBackPage != null) {
TooltipIconButton(
text = "Jump Back to Page ${jumpBackPage + 1}",
description = "Return to previous page",
onClick = onJumpBack
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.AutoMirrored.Filled.Undo,
contentDescription = "Jump Back",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Text(
text = "${jumpBackPage + 1}",
fontSize = 10.sp,
lineHeight = 10.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary
)
}
}
}
if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) { if (!hiddenTools.contains(PdfReaderTool.SLIDER.name)) {
TooltipIconButton( TooltipIconButton(
text = stringResource(R.string.tooltip_slider), text = stringResource(R.string.tooltip_slider),
@ -532,6 +591,20 @@ fun PdfBottomBar(
} }
} }
if (BuildConfig.FLAVOR != "oss") {
TooltipIconButton(
text = if (isBubbleZoomModeActive) "Exit Smart Zoom" else "Smart Comic Zoom",
description = "Toggle Smart Comic Zoom",
onClick = onToggleBubbleZoom
) {
Icon(
painterResource(R.drawable.comic_bubble),
contentDescription = "Smart Comic Zoom",
tint = if (isBubbleZoomModeActive) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
ttsErrorMessage?.let { ttsErrorMessage?.let {
Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(start = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis) Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f).padding(start = 8.dp), maxLines = 2, overflow = TextOverflow.Ellipsis)
} }

View file

@ -23,6 +23,7 @@
package com.aryan.reader.pdf package com.aryan.reader.pdf
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.RectF import android.graphics.RectF
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
@ -90,6 +91,9 @@ import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.PointerType
import androidx.compose.ui.input.pointer.isPrimaryPressed
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.isTertiaryPressed
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChanged import androidx.compose.ui.input.pointer.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker import androidx.compose.ui.input.pointer.util.VelocityTracker
@ -108,6 +112,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import com.aryan.reader.SearchResult import com.aryan.reader.SearchResult
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.data.VirtualPage
@ -217,8 +222,8 @@ internal fun PdfVerticalReader(
isEditMode: Boolean = false, isEditMode: Boolean = false,
allAnnotations: () -> Map<Int, List<PdfAnnotation>> = { emptyMap() }, allAnnotations: () -> Map<Int, List<PdfAnnotation>> = { emptyMap() },
drawingState: PdfDrawingState, drawingState: PdfDrawingState,
onDrawStart: (Int, PdfPoint) -> Unit, onDrawStart: (Int, PdfPoint, Boolean) -> Unit,
onDraw: (Int, PdfPoint) -> Unit, onDraw: (Int, PdfPoint, Boolean) -> Unit,
onDrawEnd: () -> Unit, onDrawEnd: () -> Unit,
onOcrModelDownloading: () -> Unit = {}, onOcrModelDownloading: () -> Unit = {},
selectedTool: InkType, selectedTool: InkType,
@ -247,7 +252,10 @@ internal fun PdfVerticalReader(
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(), customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: () -> Unit = {}, onPaletteClick: () -> Unit = {},
lockedState: Triple<Float, Float, Float>? = null, lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
resetZoomTrigger: Long = 0L,
isBubbleZoomModeActive: Boolean = false,
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() }
) { ) {
SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") } SideEffect { Timber.tag("PdfDrawPerf").v("LIST: PdfVerticalReader Recomposing.") }
DisposableEffect(state) { DisposableEffect(state) {
@ -260,6 +268,7 @@ internal fun PdfVerticalReader(
} }
} }
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) } var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
var isStylusEraserOverride by remember { mutableStateOf(false) }
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse" val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) { BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
@ -393,18 +402,17 @@ internal fun PdfVerticalReader(
val zoomedDocHeight = totalDocHeight * savedScale val zoomedDocHeight = totalDocHeight * savedScale
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx) val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
val maxPanY = headerHeightPx
zoomAnimatable.stop() zoomAnimatable.stop()
panXAnimatable.stop() panXAnimatable.stop()
panYAnimatable.stop() panYAnimatable.stop()
panXAnimatable.updateBounds(minPanX, maxPanX) panXAnimatable.updateBounds(minPanX, maxPanX)
panYAnimatable.updateBounds(minPanY, maxPanY) panYAnimatable.updateBounds(minPanY, headerHeightPx)
zoomAnimatable.snapTo(savedScale) zoomAnimatable.snapTo(savedScale)
panXAnimatable.snapTo(savedPanX) panXAnimatable.snapTo(savedPanX)
panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, maxPanY)) panYAnimatable.snapTo(savedPanY.coerceIn(minPanY, headerHeightPx))
Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}") Timber.tag("PdfLockDiagnostic").d("RESTORE SNAP COMPLETE: Scale=${zoomAnimatable.value}, X=${panXAnimatable.value}, Y=${panYAnimatable.value}")
@ -492,6 +500,65 @@ internal fun PdfVerticalReader(
return clampValues(targetZoom, targetPanX, targetPanY) return clampValues(targetZoom, targetPanX, targetPanY)
} }
LaunchedEffect(resetZoomTrigger) {
if (resetZoomTrigger != 0L && zoomAnimatable.value > fitZoom && !isScrollLocked) {
scope.launch {
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
val startZoom = zoomAnimatable.value
val startPanX = panXAnimatable.value
val startPanY = panYAnimatable.value
val pivotScreenX = screenWidth / 2f
val pivotScreenY = screenHeight / 2f
val pivotContentX = (pivotScreenX - startPanX) / startZoom
val pivotContentY = (pivotScreenY - startPanY) / startZoom
val rawNextPanX = pivotScreenX - (pivotContentX * fitZoom)
val rawNextPanY = pivotScreenY - (pivotContentY * fitZoom)
val (finalZoom, finalX, finalY) = clampCamera(fitZoom, rawNextPanX, rawNextPanY)
panXAnimatable.updateBounds(
lowerBound = minOf(panXAnimatable.lowerBound ?: finalX, finalX, startPanX),
upperBound = maxOf(panXAnimatable.upperBound ?: finalX, finalX, startPanX)
)
panYAnimatable.updateBounds(
lowerBound = minOf(panYAnimatable.lowerBound ?: finalY, finalY, startPanY),
upperBound = maxOf(panYAnimatable.upperBound ?: finalY, finalY, startPanY)
)
coroutineScope {
launch { zoomAnimatable.animateTo(finalZoom, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panXAnimatable.animateTo(finalX, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
launch { panYAnimatable.animateTo(finalY, animationSpec = tween(400, easing = FastOutSlowInEasing)) }
}
onZoomChange(zoomAnimatable.value)
val zoomedDocWidth = screenWidth * finalZoom
val finalMinX: Float
val finalMaxX: Float
if (zoomedDocWidth < screenWidth) {
val centeredX = (screenWidth - zoomedDocWidth) / 2f
finalMinX = centeredX
finalMaxX = centeredX
} else {
finalMinX = -(zoomedDocWidth - screenWidth)
finalMaxX = 0f
}
panXAnimatable.updateBounds(lowerBound = finalMinX, upperBound = finalMaxX)
val zDocH = totalDocHeight * finalZoom
val minScrollY = (screenHeight - footerHeightPx - zDocH).coerceAtMost(headerHeightPx)
panYAnimatable.updateBounds(lowerBound = minScrollY, upperBound = headerHeightPx)
}
}
}
LaunchedEffect( LaunchedEffect(
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
) { ) {
@ -920,6 +987,15 @@ internal fun PdfVerticalReader(
return@awaitEachGesture return@awaitEachGesture
} }
val buttons = currentEvent.buttons
Timber.tag("StylusEraserDiagnostic").d(
"VerticalReader | Type: ${down.type} | isPrimary: ${buttons.isPrimaryPressed} | isSecondary: ${buttons.isSecondaryPressed} | isTertiary: ${buttons.isTertiaryPressed} | buttonsString: $buttons"
)
val isEraserOverride = down.type == PointerType.Eraser ||
(down.type == PointerType.Stylus && currentEvent.buttons.isSecondaryPressed)
isStylusEraserOverride = isEraserOverride
fun getPageAndPoint(screenOffset: Offset): Pair<Int, PdfPoint>? { fun getPageAndPoint(screenOffset: Offset): Pair<Int, PdfPoint>? {
val zoom = zoomAnimatable.value val zoom = zoomAnimatable.value
val panX = panXAnimatable.value val panX = panXAnimatable.value
@ -943,14 +1019,14 @@ internal fun PdfVerticalReader(
var isCanceled = false var isCanceled = false
try { try {
if (selectedTool == InkType.ERASER) { if (selectedTool == InkType.ERASER || isEraserOverride) {
globalEraserPosition = down.position globalEraserPosition = down.position
} }
val startData = getPageAndPoint(down.position) val startData = getPageAndPoint(down.position)
if (startData != null) { if (startData != null) {
val (pageIndex, point) = startData val (pageIndex, point) = startData
onDrawStart(pageIndex, point) onDrawStart(pageIndex, point, isEraserOverride)
down.consume() down.consume()
} }
@ -969,7 +1045,7 @@ internal fun PdfVerticalReader(
if (change == null || !change.pressed) break if (change == null || !change.pressed) break
if (change.positionChanged()) { if (change.positionChanged()) {
if (selectedTool == InkType.ERASER) { if (selectedTool == InkType.ERASER || isEraserOverride) {
globalEraserPosition = change.position globalEraserPosition = change.position
} }
@ -977,11 +1053,11 @@ internal fun PdfVerticalReader(
if (dragData != null) { if (dragData != null) {
val (pageIndex, point) = dragData val (pageIndex, point) = dragData
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER) { if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER && !isEraserOverride) {
onDrawEnd() onDrawEnd()
onDrawStart(pageIndex, point) onDrawStart(pageIndex, point, isEraserOverride)
} else { } else {
onDraw(pageIndex, point) onDraw(pageIndex, point, isEraserOverride)
} }
lastPageIndex = pageIndex lastPageIndex = pageIndex
} }
@ -993,6 +1069,7 @@ internal fun PdfVerticalReader(
onDrawEnd() onDrawEnd()
} }
globalEraserPosition = null globalEraserPosition = null
isStylusEraserOverride = false
} }
} }
} }
@ -1472,16 +1549,20 @@ internal fun PdfVerticalReader(
} }
val onDrawStartLambda = remember(page.index, onDrawStart) { val onDrawStartLambda = remember(page.index, onDrawStart) {
{ point: PdfPoint -> onDrawStart(page.index, point) } { point: PdfPoint, isEraserOverride: Boolean ->
onDrawStart(page.index, point, isEraserOverride)
}
} }
val currentOnDraw by rememberUpdatedState(onDraw) val currentOnDraw by rememberUpdatedState(onDraw)
val onDrawLambda = remember(page.index) { val onDrawLambda = remember(page.index) {
{ point: PdfPoint -> currentOnDraw(page.index, point) } { point: PdfPoint, isEraserOverride: Boolean ->
currentOnDraw(page.index, point, isEraserOverride)
}
} }
val onSingleTapLambda = remember(onPageClick) { val onSingleTapLambda = remember(onPageClick) {
{ { _: Offset? ->
selectionClearTrigger++ selectionClearTrigger++
onPageClick() onPageClick()
} }
@ -1759,7 +1840,9 @@ internal fun PdfVerticalReader(
draggingBoxId = null draggingBoxId = null
} }
}, },
draggingBoxId = draggingBoxId draggingBoxId = draggingBoxId,
isBubbleZoomModeActive = isBubbleZoomModeActive,
onDetectBubbles = onDetectBubbles
) )
} }
@ -1875,6 +1958,7 @@ internal fun PdfVerticalReader(
animationSpec = tween(durationMillis = 300), animationSpec = tween(durationMillis = 300),
label = "scrollbarAlpha" label = "scrollbarAlpha"
) )
val safeCurrentPage = if (totalPages > 0) state.currentPage.coerceIn(0, totalPages - 1) else 0
val samsungBlue = Color(0xFF4285F4) val samsungBlue = Color(0xFF4285F4)
val samsungBlueDark = Color(0xFF1976D2) val samsungBlueDark = Color(0xFF1976D2)
@ -1934,7 +2018,7 @@ internal fun PdfVerticalReader(
.alpha(scrollbarAlpha)) { .alpha(scrollbarAlpha)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedVisibility( AnimatedVisibility(
visible = isDraggingScrollbar, visible = isDraggingScrollbar && totalPages > 0,
enter = fadeIn() + androidx.compose.animation.slideInHorizontally { enter = fadeIn() + androidx.compose.animation.slideInHorizontally {
it / 2 it / 2
}, },
@ -1948,7 +2032,7 @@ internal fun PdfVerticalReader(
modifier = Modifier.padding(end = 12.dp) modifier = Modifier.padding(end = 12.dp)
) { ) {
Text( Text(
text = "${state.currentPage + 1}/${totalPages}", text = "${safeCurrentPage + 1}/$totalPages",
style = MaterialTheme.typography.titleMedium.copy( style = MaterialTheme.typography.titleMedium.copy(
fontSize = 16.sp, fontWeight = FontWeight.Bold fontSize = 16.sp, fontWeight = FontWeight.Bold
), ),
@ -2060,7 +2144,7 @@ internal fun PdfVerticalReader(
} }
} }
if (isEditMode && selectedTool == InkType.ERASER && globalEraserPosition != null) { if (isEditMode && (selectedTool == InkType.ERASER || isStylusEraserOverride) && globalEraserPosition != null) {
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
val pos = globalEraserPosition!! val pos = globalEraserPosition!!
val radiusPx = if (activeToolThickness > 0f) { val radiusPx = if (activeToolThickness > 0f) {

View file

@ -92,6 +92,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Undo
import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
@ -209,11 +210,14 @@ import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.epubreader.AutoScrollControls import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.epubreader.SystemUiMode import com.aryan.reader.epubreader.SystemUiMode
import com.aryan.reader.epubreader.TtsOverlayControls import com.aryan.reader.epubreader.TtsOverlayControls
import com.aryan.reader.epubreader.loadTapToNavigateSetting
import com.aryan.reader.epubreader.saveTapToNavigateSetting
import com.aryan.reader.fetchAiDefinition import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes import com.aryan.reader.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk import com.aryan.reader.paginatedreader.TtsChunk
@ -252,6 +256,7 @@ import org.json.JSONObject
import timber.log.Timber import timber.log.Timber
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.util.LinkedHashSet
import java.net.HttpURLConnection import java.net.HttpURLConnection
import java.net.URL import java.net.URL
import kotlin.math.PI import kotlin.math.PI
@ -290,6 +295,7 @@ fun PdfViewerScreen(
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var displayMode by remember { mutableStateOf(loadDisplayMode(context)) } var displayMode by remember { mutableStateOf(loadDisplayMode(context)) }
var tapToNavigateEnabled by remember { mutableStateOf(loadTapToNavigateSetting(context)) }
var showThemePanel by remember { mutableStateOf(false) } var showThemePanel by remember { mutableStateOf(false) }
var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) } var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) } var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) }
@ -335,9 +341,11 @@ fun PdfViewerScreen(
savePdfHiddenTools(context, newSet) savePdfHiddenTools(context, newSet)
} }
val isOss = BuildConfig.FLAVOR == "oss"
val executeWithOcrCheck = remember(hasSelectedOcrLanguage) { val executeWithOcrCheck = remember(hasSelectedOcrLanguage) {
{ action: () -> Unit -> { action: () -> Unit ->
if (hasSelectedOcrLanguage) { if (isOss || hasSelectedOcrLanguage) {
action() action()
} else { } else {
pendingActionAfterOcrSelection = action pendingActionAfterOcrSelection = action
@ -584,6 +592,9 @@ fun PdfViewerScreen(
var customHighlightColors by remember { mutableStateOf(loadCustomHighlightColors(context)) } var customHighlightColors by remember { mutableStateOf(loadCustomHighlightColors(context)) }
var showHighlightColorPicker by remember { mutableStateOf(false) } var showHighlightColorPicker by remember { mutableStateOf(false) }
var highlightColorPickerInitialSlot by remember { mutableStateOf(PdfHighlightColor.YELLOW) } var highlightColorPickerInitialSlot by remember { mutableStateOf(PdfHighlightColor.YELLOW) }
var isBubbleZoomModeActive by remember { mutableStateOf(false) }
var showBubbleZoomDownloadDialog by remember { mutableStateOf(false) }
val bubbleZoomDownloadProgress by viewModel.speechBubbleModelDownloadProgress.collectAsState()
var dockLocation by remember { mutableStateOf(initialDockLocation) } var dockLocation by remember { mutableStateOf(initialDockLocation) }
var dockOffset by remember { mutableStateOf(initialDockOffset) } var dockOffset by remember { mutableStateOf(initialDockOffset) }
@ -655,22 +666,11 @@ fun PdfViewerScreen(
snapPreviewLocation, snapPreviewLocation,
isEditMode, isEditMode,
isDockDragging, isDockDragging,
showStandardBars,
systemUiMode, systemUiMode,
statusBarHeightDp statusBarHeightDp
) { ) {
if (!isEditMode) { if (!isEditMode) {
var h = 0.dp 0.dp
if (showStandardBars) {
h += 56.dp
}
val isStatusBarVisible = systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)
if (isStatusBarVisible) {
h += statusBarHeightDp
}
h
} else { } else {
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
@ -686,6 +686,31 @@ fun PdfViewerScreen(
label = "verticalHeaderHeight" label = "verticalHeaderHeight"
) )
val targetTopOverlayInset = remember(
showStandardBars,
systemUiMode,
statusBarHeightDp
) {
if (!showStandardBars) {
0.dp
} else {
var inset = 56.dp
val isStatusBarVisible =
systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)
if (isStatusBarVisible) {
inset += statusBarHeightDp
}
inset
}
}
val topOverlayInset by animateDpAsState(
targetValue = targetTopOverlayInset,
animationSpec = tween(durationMillis = 200),
label = "topOverlayInset"
)
val verticalFooterHeight by remember( val verticalFooterHeight by remember(
dockLocation, dockLocation,
snapPreviewLocation, snapPreviewLocation,
@ -820,9 +845,155 @@ fun PdfViewerScreen(
} }
} }
} }
var isDocumentReady by remember { mutableStateOf(false) } var isDocumentReady by remember { mutableStateOf(false) }
suspend fun renderSpeechBubblePrefetchBitmap(
document: ReaderDocument,
sourcePageIndex: Int
): Bitmap? = withContext(Dispatchers.IO) {
document.openPage(sourcePageIndex)?.use { page ->
val pageWidth = page.getPageWidthPoint()
val pageHeight = page.getPageHeightPoint()
if (pageWidth <= 0 || pageHeight <= 0) {
return@withContext null
}
val longEdge = max(pageWidth, pageHeight).toFloat()
val targetLongEdge = when (document) {
is PdfDocumentWrapper -> 1600f.coerceAtLeast(longEdge)
else -> min(longEdge, 1600f)
}
val renderScale = (targetLongEdge / longEdge).coerceAtLeast(1f)
val renderWidth = (pageWidth * renderScale).roundToInt().coerceAtLeast(1)
val renderHeight = (pageHeight * renderScale).roundToInt().coerceAtLeast(1)
val renderBitmap = Bitmap.createBitmap(renderWidth, renderHeight, Bitmap.Config.ARGB_8888)
try {
page.renderPageBitmap(
bitmap = renderBitmap,
startX = 0,
startY = 0,
drawSizeX = renderWidth,
drawSizeY = renderHeight,
renderAnnot = true
)
renderBitmap
} catch (t: Throwable) {
renderBitmap.recycle()
Timber.tag("BubbleZoom").w(t, "Failed to render bubble prefetch bitmap for page $sourcePageIndex")
null
}
}
}
fun buildSpeechBubblePrefetchOrder(): List<Int> {
if (totalDisplayPages <= 0) return emptyList()
val ordered = LinkedHashSet<Int>()
ordered += currentPage.coerceIn(0, totalDisplayPages - 1)
for (distance in 1 until totalDisplayPages) {
val next = currentPage + distance
val previous = currentPage - distance
if (next in 0 until totalDisplayPages) ordered += next
if (previous in 0 until totalDisplayPages) ordered += previous
}
return ordered.toList()
}
suspend fun detectSpeechBubblesForPage(
sourcePageIndex: Int,
fallbackBitmap: Bitmap,
allowHighQualityFallback: Boolean = true
): List<SpeechBubble> {
val document = pdfDocument
val shouldUsePrefetchBitmap =
allowHighQualityFallback &&
document != null &&
!viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex)
val detectionBitmap = if (shouldUsePrefetchBitmap) {
renderSpeechBubblePrefetchBitmap(document!!, sourcePageIndex) ?: fallbackBitmap
} else {
fallbackBitmap
}
val ownsBitmap = detectionBitmap !== fallbackBitmap
return try {
val detected = viewModel.detectSpeechBubblesCached(
documentId = bookId,
pageIndex = sourcePageIndex,
bitmap = detectionBitmap,
context = context
)
if (ownsBitmap) {
viewModel.detectSpeechBubblesCached(
documentId = bookId,
pageIndex = sourcePageIndex,
bitmap = fallbackBitmap,
context = context
)
} else {
detected
}
} finally {
if (ownsBitmap && !detectionBitmap.isRecycled) {
detectionBitmap.recycle()
}
}
}
LaunchedEffect(
isBubbleZoomModeActive,
isDocumentReady,
pdfDocument,
bookId,
currentPage,
totalDisplayPages,
virtualPages
) {
val document = pdfDocument ?: return@LaunchedEffect
if (!isBubbleZoomModeActive || !isDocumentReady || totalDisplayPages <= 0) {
return@LaunchedEffect
}
for (displayPageIndex in buildSpeechBubblePrefetchOrder()) {
if (!isActive) break
val sourcePageIndex = when (val virtualPage = virtualPages.getOrNull(displayPageIndex)) {
is VirtualPage.PdfPage -> virtualPage.pdfIndex
null -> displayPageIndex
else -> continue
}
if (viewModel.hasCachedSpeechBubbles(bookId, sourcePageIndex)) {
continue
}
val prefetchBitmap = renderSpeechBubblePrefetchBitmap(document, sourcePageIndex) ?: continue
try {
detectSpeechBubblesForPage(
sourcePageIndex = sourcePageIndex,
fallbackBitmap = prefetchBitmap,
allowHighQualityFallback = false
)
} finally {
if (!prefetchBitmap.isRecycled) {
prefetchBitmap.recycle()
}
}
kotlinx.coroutines.yield()
}
}
val jumpHistory = remember { mutableStateListOf<Int>() }
var showJumpPill by remember { mutableStateOf(false) }
LaunchedEffect(showJumpPill, jumpHistory.size) {
if (showJumpPill && jumpHistory.isNotEmpty()) {
delay(4000)
showJumpPill = false
}
}
LaunchedEffect(currentPage, isDocumentReady, totalPages, initialScrollDone) { LaunchedEffect(currentPage, isDocumentReady, totalPages, initialScrollDone) {
if (isDocumentReady && totalPages > 0) { if (isDocumentReady && totalPages > 0) {
if (initialScrollDone) { if (initialScrollDone) {
@ -989,6 +1160,7 @@ fun PdfViewerScreen(
var isLoadingDocument by remember { mutableStateOf(true) } var isLoadingDocument by remember { mutableStateOf(true) }
var selectionClearTrigger by remember { mutableLongStateOf(0L) } var selectionClearTrigger by remember { mutableLongStateOf(0L) }
var resetZoomTrigger by remember { mutableLongStateOf(0L) }
val displayPageRatios by remember(pageAspectRatios, virtualPages) { val displayPageRatios by remember(pageAspectRatios, virtualPages) {
derivedStateOf { derivedStateOf {
@ -1879,7 +2051,6 @@ fun PdfViewerScreen(
val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) { val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) {
{ text: String -> { text: String ->
executeWithOcrCheck { executeWithOcrCheck {
val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary val effectiveUseOnline = !isOss && useOnlineDictionary
if (effectiveUseOnline) { if (effectiveUseOnline) {
@ -1954,6 +2125,14 @@ fun PdfViewerScreen(
{ targetPage: Int -> { targetPage: Int ->
coroutineScope.launch { coroutineScope.launch {
if (targetPage in 0 until totalPages) { if (targetPage in 0 until totalPages) {
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
if (current != targetPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(current)
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) { if (displayMode == DisplayMode.PAGINATION) {
pagerState.animateScrollToPage(targetPage) pagerState.animateScrollToPage(targetPage)
} else { } else {
@ -2700,19 +2879,8 @@ fun PdfViewerScreen(
} }
} }
LaunchedEffect(pagerState.isScrollInProgress) {
if (pagerState.isScrollInProgress && showBars) {
showBars = false
Timber.d("Pager scroll detected, hiding bars.")
}
}
LaunchedEffect(pagerState.isScrollInProgress) { LaunchedEffect(pagerState.isScrollInProgress) {
if (pagerState.isScrollInProgress) { if (pagerState.isScrollInProgress) {
if (showBars) {
showBars = false
Timber.d("Pager scroll detected, hiding bars.")
}
if (displayMode == DisplayMode.PAGINATION && !isAutoPagingForTts && (ttsState.isPlaying || ttsState.isLoading)) { if (displayMode == DisplayMode.PAGINATION && !isAutoPagingForTts && (ttsState.isPlaying || ttsState.isLoading)) {
ttsController.stop() ttsController.stop()
} }
@ -2986,6 +3154,14 @@ fun PdfViewerScreen(
val onInternalLinkNav: (Int) -> Unit = { targetPage -> val onInternalLinkNav: (Int) -> Unit = { targetPage ->
coroutineScope.launch { coroutineScope.launch {
if (targetPage in 0 until totalPages) { if (targetPage in 0 until totalPages) {
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
if (current != targetPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(current)
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) { if (displayMode == DisplayMode.PAGINATION) {
pagerState.animateScrollToPage(targetPage) pagerState.animateScrollToPage(targetPage)
} else { } else {
@ -3014,16 +3190,24 @@ fun PdfViewerScreen(
fun navigateToPdfSearchResult(result: SearchResult) { fun navigateToPdfSearchResult(result: SearchResult) {
currentPdfSearchResult = result currentPdfSearchResult = result
searchHighlightTarget = result searchHighlightTarget = result
coroutineScope.launch { coroutineScope.launch {
val targetPage = result.locationInSource
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
if (current != targetPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(current)
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) { if (displayMode == DisplayMode.PAGINATION) {
if (pagerState.currentPage != result.locationInSource) { if (pagerState.currentPage != targetPage) {
pagerState.scrollToPage(result.locationInSource) pagerState.scrollToPage(targetPage)
} }
} else { } else {
verticalReaderState.scrollToPage(result.locationInSource) verticalReaderState.scrollToPage(targetPage)
} }
} }
} }
@ -3092,13 +3276,23 @@ fun PdfViewerScreen(
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = { drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) { ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
PdfNavigationDrawerContent( PdfNavigationDrawerContent(
pdfDocument = pdfDocument,
flatTableOfContents = flatTableOfContents, flatTableOfContents = flatTableOfContents,
bookmarks = bookmarks, bookmarks = bookmarks,
userHighlights = userHighlights, userHighlights = userHighlights,
currentPage = currentPage, currentPage = currentPage,
totalPages = totalDisplayPages,
customHighlightColors = customHighlightColors, customHighlightColors = customHighlightColors,
onPageSelected = { targetPage -> onPageSelected = { targetPage ->
coroutineScope.launch { coroutineScope.launch {
val current = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
if (current != targetPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(current)
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) { if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage(targetPage) pagerState.scrollToPage(targetPage)
} else { } else {
@ -3203,6 +3397,44 @@ fun PdfViewerScreen(
val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) } val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) }
when (displayMode) { when (displayMode) {
DisplayMode.PAGINATION -> { DisplayMode.PAGINATION -> {
val onPaginationPreSingleTap: (Offset) -> Boolean = { tapOffset ->
val canTurnPagesByTap = tapToNavigateEnabled &&
(currentPageScale <= 1.02f || isScrollLocked)
if (!canTurnPagesByTap) {
false
} else {
val oneQuarterWidthPx = boxMaxWidthFloat / 4f
when {
tapOffset.x < oneQuarterWidthPx -> {
coroutineScope.launch {
val targetPage =
(pagerState.currentPage - 1).coerceAtLeast(0)
if (targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage)
}
}
true
}
tapOffset.x > (boxMaxWidthFloat - oneQuarterWidthPx) -> {
coroutineScope.launch {
val targetPage =
(pagerState.currentPage + 1).coerceAtMost(
pagerState.pageCount - 1
)
if (targetPage != pagerState.currentPage) {
pagerState.scrollToPage(targetPage)
}
}
true
}
else -> false
}
}
}
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
@ -3291,9 +3523,10 @@ fun PdfViewerScreen(
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination = @Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
remember(pageIndex) { remember(pageIndex) {
{ point: PdfPoint -> { point: PdfPoint, isEraserOverride: Boolean ->
if (currentSelectedTool == InkType.TEXT) { val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
} else if (currentSelectedTool == InkType.ERASER) { if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList() val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter { val toRemove = existing.filter {
@ -3328,12 +3561,13 @@ fun PdfViewerScreen(
@Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination = @Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination =
remember(pageIndex) { remember(pageIndex) {
{ point: PdfPoint -> { point: PdfPoint, isEraserOverride: Boolean ->
if (showToolSettings) { if (showToolSettings) {
showToolSettings = false showToolSettings = false
} else { } else {
if (currentSelectedTool == InkType.TEXT) { val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
} else if (currentSelectedTool == InkType.ERASER) { if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point lastEraserPoint = point
erasedAnnotationsFromStroke.clear() erasedAnnotationsFromStroke.clear()
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
@ -3362,7 +3596,7 @@ fun PdfViewerScreen(
drawingState.onDrawStart( drawingState.onDrawStart(
pageIndex, pageIndex,
pointWithTime, pointWithTime,
currentSelectedTool, effectiveTool,
currentStrokeColorState, currentStrokeColorState,
currentStrokeWidthState currentStrokeWidthState
) )
@ -3403,7 +3637,8 @@ fun PdfViewerScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
showAllTextHighlights = showAllTextHighlights, showAllTextHighlights = showAllTextHighlights,
onHighlightLoading = { /* no-op for paginated mode */ }, onHighlightLoading = { /* no-op for paginated mode */ },
onSingleTap = onSingleTapStable, onPreSingleTap = onPaginationPreSingleTap,
onSingleTap = { _ -> onSingleTapStable() },
isProUser = isProUser, isProUser = isProUser,
onShowDictionaryUpsellDialog = { onShowDictionaryUpsellDialog = {
if (useOnlineDictionary) { if (useOnlineDictionary) {
@ -3420,6 +3655,7 @@ fun PdfViewerScreen(
onBookmarkClick = { onToggleBookmark(pageIndex) }, onBookmarkClick = { onToggleBookmark(pageIndex) },
isZoomEnabled = true, isZoomEnabled = true,
clearSelectionTrigger = selectionClearTrigger, clearSelectionTrigger = selectionClearTrigger,
resetZoomTrigger = resetZoomTrigger,
pageAnnotations = pageAnnotationsProvider, pageAnnotations = pageAnnotationsProvider,
drawingState = drawingState, drawingState = drawingState,
onDrawStart = onDrawStartPagination, onDrawStart = onDrawStartPagination,
@ -3470,12 +3706,11 @@ fun PdfViewerScreen(
currentActiveOffset = newOffset currentActiveOffset = newOffset
} }
}, },
onDetectPanels = { bitmap -> onDetectBubbles = { sourcePageIndex, bitmap ->
Toast.makeText(context, "Scanning for panels...", Toast.LENGTH_SHORT).show() detectSpeechBubblesForPage(sourcePageIndex, bitmap)
viewModel.detectComicPanels(bitmap, context)
}, },
onShowPanelPopup = { croppedBitmap -> onShowPanelPopup = { bitmapWithRects ->
poppedUpPanelBitmap = croppedBitmap poppedUpPanelBitmap = bitmapWithRects
}, },
onTwoFingerSwipe = { direction -> onTwoFingerSwipe = { direction ->
coroutineScope.launch { coroutineScope.launch {
@ -3647,7 +3882,15 @@ fun PdfViewerScreen(
paginationDraggingBoxId = null paginationDraggingBoxId = null
} }
}, },
onDragPageTurn = { /* Handled in onTextBoxDrag */ }, onDragPageTurn = { direction ->
coroutineScope.launch {
val targetPage = pagerState.currentPage + direction
if (targetPage in 0 until totalDisplayPages) {
pagerState.animateScrollToPage(targetPage)
}
}
},
isBubbleZoomModeActive = isBubbleZoomModeActive,
isVisible = isVisiblePage, isVisible = isVisiblePage,
isActivePage = pagerState.currentPage == pageIndex, isActivePage = pagerState.currentPage == pageIndex,
isScrolling = pagerState.isScrollInProgress isScrolling = pagerState.isScrollInProgress
@ -3718,12 +3961,13 @@ fun PdfViewerScreen(
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable = @Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
remember { remember {
{ pageIndex: Int, point: PdfPoint -> { pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
if (showToolSettings) { if (showToolSettings) {
showToolSettings = false showToolSettings = false
} else { } else {
if (currentSelectedTool == InkType.TEXT) { val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
} else if (currentSelectedTool == InkType.ERASER) { if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point lastEraserPoint = point
erasedAnnotationsFromStroke.clear() erasedAnnotationsFromStroke.clear()
@ -3753,7 +3997,7 @@ fun PdfViewerScreen(
drawingState.onDrawStart( drawingState.onDrawStart(
pageIndex, pageIndex,
pointWithTime, pointWithTime,
currentSelectedTool, effectiveTool,
currentStrokeColorState, currentStrokeColorState,
currentStrokeWidthState currentStrokeWidthState
) )
@ -3763,8 +4007,9 @@ fun PdfViewerScreen(
} }
val onDrawStable = remember(isHighlighterSnapEnabled, isCurrentToolHighlighter, calculateSnappedPoint) { val onDrawStable = remember(isHighlighterSnapEnabled, isCurrentToolHighlighter, calculateSnappedPoint) {
{ pageIndex: Int, point: PdfPoint -> { pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
if (currentSelectedTool == InkType.ERASER) { val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.ERASER) {
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f } val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList() val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter { val toRemove = existing.filter {
@ -3915,6 +4160,11 @@ fun PdfViewerScreen(
onZoomAndPanChanged = { newScale, newOffset -> onZoomAndPanChanged = { newScale, newOffset ->
currentActiveScale = newScale currentActiveScale = newScale
currentActiveOffset = newOffset currentActiveOffset = newOffset
},
resetZoomTrigger = resetZoomTrigger,
isBubbleZoomModeActive = isBubbleZoomModeActive,
onDetectBubbles = { sourcePageIndex, bitmap ->
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
} }
) )
} }
@ -4108,7 +4358,7 @@ fun PdfViewerScreen(
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.fillMaxWidth() .fillMaxWidth()
.padding(top = if (showBars) verticalHeaderHeight else 0.dp) .padding(top = topOverlayInset)
.padding(8.dp) .padding(8.dp)
) { ) {
Surface( Surface(
@ -4138,6 +4388,55 @@ fun PdfViewerScreen(
} }
} }
AnimatedVisibility(
visible = bubbleZoomDownloadProgress != null,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut(),
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
// shift down slightly if the OCR indicator is also showing
.padding(top = topOverlayInset + if (isOcrModelDownloading) 64.dp else 0.dp)
.padding(8.dp)
) {
Surface(
color = MaterialTheme.colorScheme.tertiaryContainer,
shape = RoundedCornerShape(8.dp),
shadowElevation = 4.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
val progress = bubbleZoomDownloadProgress ?: 0f
if (progress > 0f) {
CircularProgressIndicator(
progress = { progress },
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onTertiaryContainer,
trackColor = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.2f)
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Downloading Bubble Zoom model... ${(progress * 100).toInt()}%",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
}
}
}
// --- Slider UI Overlay --- // --- Slider UI Overlay ---
AnimatedVisibility( AnimatedVisibility(
visible = isPageSliderVisible, visible = isPageSliderVisible,
@ -4209,6 +4508,15 @@ fun PdfViewerScreen(
scrubDebounceJob.value = coroutineScope.launch { scrubDebounceJob.value = coroutineScope.launch {
delay(200) delay(200)
if (isActive) { if (isActive) {
val targetPage = newValue.roundToInt()
if (targetPage != sliderStartPage) {
if (jumpHistory.lastOrNull() != sliderStartPage) {
if (jumpHistory.size > 20) jumpHistory.removeAt(0)
jumpHistory.add(sliderStartPage)
}
showJumpPill = true
}
if (displayMode == DisplayMode.PAGINATION) { if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage( pagerState.scrollToPage(
newValue.roundToInt() newValue.roundToInt()
@ -4418,10 +4726,17 @@ fun PdfViewerScreen(
}, },
onShowCustomizeTools = { showCustomizeToolsSheet = true }, onShowCustomizeTools = { showCustomizeToolsSheet = true },
onShowOcrLanguage = { onShowOcrLanguage = {
if (!isOss) {
hasSelectedOcrLanguage = true hasSelectedOcrLanguage = true
showOcrLanguageDialog = true showOcrLanguageDialog = true
}
}, },
onShowVisualOptions = { showVisualOptionsSheet = true }, onShowVisualOptions = { showVisualOptionsSheet = true },
tapToNavigateEnabled = tapToNavigateEnabled,
onToggleTapToNavigate = {
tapToNavigateEnabled = !tapToNavigateEnabled
saveTapToNavigateSetting(context, tapToNavigateEnabled)
},
onChangeDisplayMode = { displayMode = it }, onChangeDisplayMode = { displayMode = it },
onToggleKeepScreenOn = { onToggleKeepScreenOn = {
isKeepScreenOn = !isKeepScreenOn isKeepScreenOn = !isKeepScreenOn
@ -4481,13 +4796,28 @@ fun PdfViewerScreen(
} }
} }
}, },
onNewTabClick = { showNewTabSheet = true } onNewTabClick = { showNewTabSheet = true },
onGenerateDemoAnnotations = {
val page = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
val demoAnnots = DemoAnnotationGenerator.generateDemoAnnotations(page)
if (demoAnnots.isNotEmpty()) {
Timber.d("Debug: Generating ${demoAnnots.size} demo annotations for page $page")
val existing = allAnnotations[page] ?: emptyList()
allAnnotations = allAnnotations + (page to (existing + demoAnnots))
demoAnnots.forEach { annot ->
undoStack.add(HistoryAction.Add(page, annot))
}
redoStack.clear()
}
}
) )
ReflowProgressOverlay( ReflowProgressOverlay(
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(top = verticalHeaderHeight) .padding(top = topOverlayInset)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp), .padding(horizontal = 8.dp),
showStandardBars = showStandardBars, showStandardBars = showStandardBars,
@ -4502,7 +4832,7 @@ fun PdfViewerScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(top = verticalHeaderHeight) .padding(top = topOverlayInset)
.background(MaterialTheme.colorScheme.surface) .background(MaterialTheme.colorScheme.surface)
) { ) {
if (isBackgroundIndexing) { if (isBackgroundIndexing) {
@ -4673,6 +5003,56 @@ fun PdfViewerScreen(
) )
} }
val effectiveNavBarForPill = if (systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)) with(density) { navBarHeight.toDp() } else 0.dp
val isBottomBarVisibleForPill = showStandardBars && !searchState.isSearchActive
val targetPillBottomPadding = if (isBottomBarVisibleForPill) 56.dp + 16.dp + effectiveNavBarForPill else 16.dp + effectiveNavBarForPill
val pillBottomPadding by animateDpAsState(
targetValue = targetPillBottomPadding,
label = "PillBottomPadding"
)
AnimatedVisibility(
visible = showJumpPill && jumpHistory.isNotEmpty(),
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it },
modifier = Modifier
.align(Alignment.BottomStart)
.padding(bottom = pillBottomPadding)
.padding(start = 16.dp)
) {
val lastPage = jumpHistory.lastOrNull() ?: 0
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
shadowElevation = 6.dp,
onClick = {
val target = jumpHistory.removeLastOrNull()
if (target != null) {
showJumpPill = false
coroutineScope.launch {
if (displayMode == DisplayMode.PAGINATION) {
pagerState.animateScrollToPage(target)
} else {
verticalReaderState.scrollToPage(target)
}
}
}
}
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.AutoMirrored.Filled.Undo, contentDescription = "Jump Back", modifier = Modifier.size(18.dp))
Spacer(modifier = Modifier.width(8.dp))
Text("Back to Pg ${lastPage + 1}", style = MaterialTheme.typography.labelLarge)
}
}
}
// Bottom Bar // Bottom Bar
PdfBottomBar( PdfBottomBar(
modifier = Modifier.align(Alignment.BottomCenter), modifier = Modifier.align(Alignment.BottomCenter),
@ -4687,6 +5067,20 @@ fun PdfViewerScreen(
isEditMode = isEditMode, isEditMode = isEditMode,
isTtsSessionActive = isTtsSessionActive, isTtsSessionActive = isTtsSessionActive,
ttsErrorMessage = ttsState.errorMessage, ttsErrorMessage = ttsState.errorMessage,
jumpBackPage = jumpHistory.lastOrNull(),
onJumpBack = {
val target = jumpHistory.removeLastOrNull()
if (target != null) {
showJumpPill = false
coroutineScope.launch {
if (displayMode == DisplayMode.PAGINATION) {
pagerState.animateScrollToPage(target)
} else {
verticalReaderState.scrollToPage(target)
}
}
}
},
onShowSlider = { onShowSlider = {
val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
sliderStartPage = currentPageForSlider sliderStartPage = currentPageForSlider
@ -4736,6 +5130,16 @@ fun PdfViewerScreen(
} else { } else {
startTtsWithPermissionCheck(null, null) startTtsWithPermissionCheck(null, null)
} }
},
isBubbleZoomModeActive = isBubbleZoomModeActive,
onToggleBubbleZoom = {
if (isOss) {
coroutineScope.launch { snackbarHostState.showSnackbar("Bubble Zoom is only available in Playstore version of Episteme") }
} else if (!isBubbleZoomModeActive && !viewModel.isSpeechBubbleModelAvailable(context)) {
showBubbleZoomDownloadDialog = true
} else {
isBubbleZoomModeActive = !isBubbleZoomModeActive
}
} }
) )
@ -5145,7 +5549,12 @@ fun PdfViewerScreen(
exit = fadeOut() exit = fadeOut()
) { ) {
val percentage = (currentPageScale * 100).roundToInt() val percentage = (currentPageScale * 100).roundToInt()
ZoomPercentageIndicator(percentage = percentage) ZoomPercentageIndicator(
percentage = percentage,
onResetZoomClick = {
resetZoomTrigger = System.currentTimeMillis()
}
)
} }
val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0 val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
@ -5465,7 +5874,7 @@ fun PdfViewerScreen(
) { ) {
Image( Image(
bitmap = poppedUpPanelBitmap!!.asImageBitmap(), bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
contentDescription = "Zoomed Panel", contentDescription = "Annotated Page",
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
@ -5485,7 +5894,7 @@ fun PdfViewerScreen(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Close, imageVector = Icons.Default.Close,
contentDescription = "Close Panel", contentDescription = "Close Image",
tint = Color.White tint = Color.White
) )
} }
@ -5500,6 +5909,30 @@ fun PdfViewerScreen(
onConfirm = { password -> documentPassword = password }) onConfirm = { password -> documentPassword = password })
} }
if (showBubbleZoomDownloadDialog) {
AlertDialog(
onDismissRequest = { showBubbleZoomDownloadDialog = false },
icon = { Icon(Icons.Default.Info, contentDescription = null) },
title = { Text("Download Bubble Zoom Model") },
text = {
Text("To use the Bubble Zoom feature, an AI model needs to be downloaded (~134 MB). Do you want to download it now?")
},
confirmButton = {
TextButton(onClick = {
showBubbleZoomDownloadDialog = false
viewModel.downloadSpeechBubbleModel(context)
}) {
Text("Download")
}
},
dismissButton = {
TextButton(onClick = { showBubbleZoomDownloadDialog = false }) {
Text(stringResource(R.string.action_cancel))
}
}
)
}
if (showNewTabSheet) { if (showNewTabSheet) {
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = { showNewTabSheet = false }, onDismissRequest = { showNewTabSheet = false },
@ -5656,7 +6089,7 @@ fun PdfViewerScreen(
} }
} }
if (showOcrLanguageDialog) { if (showOcrLanguageDialog && !isOss) {
OcrLanguageSelectionDialog( OcrLanguageSelectionDialog(
currentLanguage = ocrLanguage, currentLanguage = ocrLanguage,
isFirstRun = !hasSelectedOcrLanguage, isFirstRun = !hasSelectedOcrLanguage,
@ -6037,6 +6470,17 @@ fun PdfViewerScreen(
currentTtsMode = currentTtsMode, currentTtsMode = currentTtsMode,
isCollapsed = isTtsCollapsed, isCollapsed = isTtsCollapsed,
onCollapseChange = { isTtsCollapsed = it }, onCollapseChange = { isTtsCollapsed = it },
onLocateCurrentChunk = {
ttsPageData?.pageIndex?.let { targetPage ->
coroutineScope.launch {
if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage(targetPage)
} else {
verticalReaderState.scrollToPage(targetPage)
}
}
}
},
onOpenTtsSettings = { showTtsSettingsSheet = true }, onOpenTtsSettings = { showTtsSettingsSheet = true },
onClose = { onClose = {
ttsController.stop() ttsController.stop()

View file

@ -147,7 +147,35 @@ class BaseTtsSynthesizer(private val context: Context) {
if (tts == null) return if (tts == null) return
try { try {
val preferredVoiceName = loadNativeVoice(context) ?: return val preferredVoiceName = loadNativeVoice(context)
if (preferredVoiceName.isNullOrBlank()) {
val defaultLocale = Locale.getDefault()
try {
tts?.language = defaultLocale
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to restore default language")
}
val defaultVoice = try {
tts?.defaultVoice ?: tts?.voices?.firstOrNull { voice ->
voice.locale == defaultLocale && !voice.isNetworkConnectionRequired
} ?: tts?.voices?.firstOrNull { voice ->
voice.locale == defaultLocale
}
} catch (e: Exception) {
Timber.e(e, "BaseTts: Failed to query default voice")
null
}
if (defaultVoice != null && tts?.voice?.name != defaultVoice.name) {
Timber.d("BaseTts: Restoring system default voice to ${defaultVoice.name} (${defaultVoice.locale})")
tts?.voice = defaultVoice
} else {
Timber.d("BaseTts: Using engine default voice for locale $defaultLocale")
}
return
}
if (tts?.voice?.name == preferredVoiceName) return if (tts?.voice?.name == preferredVoiceName) return

View file

@ -157,6 +157,7 @@ class TtsController(context: Context) : Player.Listener {
bookTitle: String, bookTitle: String,
chapterTitle: String?, chapterTitle: String?,
coverImageUri: String?, coverImageUri: String?,
chapterIndex: Int? = null,
ttsMode: TtsPlaybackManager.TtsMode, ttsMode: TtsPlaybackManager.TtsMode,
playbackSource: String = "READER", playbackSource: String = "READER",
authToken: String? = null authToken: String? = null
@ -179,6 +180,7 @@ class TtsController(context: Context) : Player.Listener {
putString(KEY_BOOK_TITLE, bookTitle) putString(KEY_BOOK_TITLE, bookTitle)
putString(KEY_CHAPTER_TITLE, chapterTitle) putString(KEY_CHAPTER_TITLE, chapterTitle)
putString(KEY_COVER_IMAGE_URI, coverImageUri) putString(KEY_COVER_IMAGE_URI, coverImageUri)
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
putString(KEY_TTS_MODE, ttsMode.name) putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource) putString(KEY_PLAYBACK_SOURCE, playbackSource)
putString(KEY_AUTH_TOKEN, authToken) putString(KEY_AUTH_TOKEN, authToken)
@ -252,6 +254,8 @@ class TtsController(context: Context) : Player.Listener {
val isLoading = customState.getBoolean("isLoading", false) val isLoading = customState.getBoolean("isLoading", false)
val sessionFinished = customState.getBoolean("sessionFinished", false) val sessionFinished = customState.getBoolean("sessionFinished", false)
val playbackSource = customState.getString("playbackSource") val playbackSource = customState.getString("playbackSource")
val serviceBookTitle = customState.getString("bookTitle")
val serviceChapterIndex = customState.getInt("chapterIndex", -1).takeIf { it >= 0 }
val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras val mediaItemExtras = currentMediaItem?.mediaMetadata?.extras
val sourceCfi = mediaItemExtras?.getString("sourceCfi") val sourceCfi = mediaItemExtras?.getString("sourceCfi")
@ -270,6 +274,16 @@ class TtsController(context: Context) : Player.Listener {
if (isLoading) currentState.currentText else null if (isLoading) currentState.currentText else null
}, },
errorMessage = customState.getString("errorMessage"), errorMessage = customState.getString("errorMessage"),
bookTitle = if (isPlaybackActive) {
currentMediaItem?.mediaMetadata?.artist?.toString() ?: serviceBookTitle
} else {
if (isLoading) currentState.bookTitle else serviceBookTitle
},
chapterIndex = if (isPlaybackActive || isLoading) {
serviceChapterIndex ?: currentState.chapterIndex
} else {
serviceChapterIndex
},
speakerId = serviceSpeaker, speakerId = serviceSpeaker,
sourceCfi = if (isPlaybackActive) { sourceCfi = if (isPlaybackActive) {
sourceCfi sourceCfi

View file

@ -70,6 +70,7 @@ const val KEY_WORD_TIMESTAMPS = "KEY_WORD_TIMESTAMPS"
const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS" const val KEY_WORD_OFFSETS = "KEY_WORD_OFFSETS"
const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE" const val KEY_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN" const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX"
private const val PREFETCH_LOOKAHEAD = 3 private const val PREFETCH_LOOKAHEAD = 3
@ -100,6 +101,8 @@ class TtsPlaybackManager(
val isLoading: Boolean = false, val isLoading: Boolean = false,
val currentText: String? = null, val currentText: String? = null,
val errorMessage: String? = null, val errorMessage: String? = null,
val bookTitle: String? = null,
val chapterIndex: Int? = null,
val speakerId: String = DEFAULT_SPEAKER_ID, val speakerId: String = DEFAULT_SPEAKER_ID,
val sourceCfi: String? = null, val sourceCfi: String? = null,
val startOffsetInSource: Int = -1, val startOffsetInSource: Int = -1,
@ -189,6 +192,7 @@ class TtsPlaybackManager(
val bookTitle = args.getString(KEY_BOOK_TITLE) val bookTitle = args.getString(KEY_BOOK_TITLE)
val chapterTitle = args.getString(KEY_CHAPTER_TITLE) val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
val coverImageUri = args.getString(KEY_COVER_IMAGE_URI) val coverImageUri = args.getString(KEY_COVER_IMAGE_URI)
val chapterIndex = args.getInt(KEY_CHAPTER_INDEX, -1).takeIf { it >= 0 }
val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name) val ttsModeName = args.getString(KEY_TTS_MODE, TtsMode.CLOUD.name)
val playbackSource = args.getString(KEY_PLAYBACK_SOURCE) val playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD } val ttsMode = try { TtsMode.valueOf(ttsModeName ?: TtsMode.CLOUD.name) } catch (_: Exception) { TtsMode.CLOUD }
@ -204,7 +208,7 @@ class TtsPlaybackManager(
val authToken = args.getString(KEY_AUTH_TOKEN) val authToken = args.getString(KEY_AUTH_TOKEN)
Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}") Timber.tag("TTS_CLOUD_DIAG").d("TtsPlaybackManager received START. Token present: ${!authToken.isNullOrBlank()}")
handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, ttsMode, playbackSource, args) handleStartTts(richChunks, speakerId, bookTitle, chapterTitle, coverImageUri, chapterIndex, ttsMode, playbackSource, args)
} }
STOP_TTS_COMMAND -> { STOP_TTS_COMMAND -> {
Timber.d("Received STOP command.") Timber.d("Received STOP command.")
@ -337,6 +341,7 @@ class TtsPlaybackManager(
bookTitle: String?, bookTitle: String?,
chapterTitle: String?, chapterTitle: String?,
coverImageUri: String?, coverImageUri: String?,
chapterIndex: Int?,
ttsMode: TtsMode, ttsMode: TtsMode,
playbackSource: String?, playbackSource: String?,
args: Bundle // Added this parameter args: Bundle // Added this parameter
@ -375,6 +380,8 @@ class TtsPlaybackManager(
_ttsState.value = TtsState( _ttsState.value = TtsState(
isLoading = true, isLoading = true,
bookTitle = bookTitle,
chapterIndex = chapterIndex,
speakerId = speakerId, speakerId = speakerId,
playbackSource = playbackSource, playbackSource = playbackSource,
ttsMode = ttsMode.name ttsMode = ttsMode.name
@ -857,6 +864,8 @@ class TtsPlaybackManager(
val bundle = Bundle().apply { val bundle = Bundle().apply {
putBoolean("isLoading", state.isLoading) putBoolean("isLoading", state.isLoading)
putString("errorMessage", state.errorMessage) putString("errorMessage", state.errorMessage)
putString("bookTitle", state.bookTitle)
putInt("chapterIndex", state.chapterIndex ?: -1)
putString("speakerId", state.speakerId) putString("speakerId", state.speakerId)
putBoolean("sessionEndedByStop", state.sessionEndedByStop) putBoolean("sessionEndedByStop", state.sessionEndedByStop)
putString("currentWordSourceCfi", state.currentWordSourceCfi) putString("currentWordSourceCfi", state.currentWordSourceCfi)

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M440,157L357,240L240,240L240,357L157,440L240,523L240,640L357,640L440,723L540,623L708,708L622,541L723,440L640,357L640,240L523,240L440,157ZM440,44L556,160L720,160L720,324L836,440L720,556L835,782Q842,795 839,807.5Q836,820 828,828Q820,836 807.5,839Q795,842 782,835L556,720L440,836L324,720L160,720L160,556L44,440L160,324L160,160L324,160L440,44ZM440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440L440,440Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M480,659Q579,579 629.5,505Q680,431 680,366Q680,276 624,218Q568,160 480,160Q392,160 336,218Q280,276 280,366Q280,431 330.5,505Q381,579 480,659ZM480,760Q339,656 269.5,558Q200,460 200,366Q200,241 278,160.5Q356,80 480,80Q604,80 682,160.5Q760,241 760,366Q760,460 690.5,558Q621,656 480,760ZM480,440Q513,440 536.5,416.5Q560,393 560,360Q560,327 536.5,303.5Q513,280 480,280Q447,280 423.5,303.5Q400,327 400,360Q400,393 423.5,416.5Q447,440 480,440ZM200,880L200,800L760,800L760,880L200,880ZM480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Q480,360 480,360Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M240,800L280,640L120,640L140,560L300,560L340,400L180,400L200,320L360,320L400,160L480,160L440,320L600,320L640,160L720,160L680,320L840,320L820,400L660,400L620,560L780,560L760,640L600,640L560,800L480,800L520,640L360,640L320,800L240,800ZM380,560L540,560L580,400L420,400L380,560Z"/>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M120,840L120,600L200,600L200,704L324,580L380,636L256,760L360,760L360,840L120,840ZM600,840L600,760L704,760L580,636L636,580L760,704L760,600L840,600L840,840L600,840ZM324,380L200,256L200,360L120,360L120,120L360,120L360,200L256,200L380,324L324,380ZM636,380L580,324L704,200L600,200L600,120L840,120L840,360L760,360L760,256L636,380Z"/>
</vector>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>
+AR+A7/jH//X4ZqHkEQbMv48/pP81n0EEM0O4j2DkIf0Q4zKio2V29y5k2A0RNDK8zF54e1/xQnIfI0+T6Xp/3E/c9vA43T2Z1z7zU3L5+Vb+d0xT3i1oB4x/3/KjDq/Y1yE4j1NfIq1o001zWwN+P1Qx24GjQk4/z8/Ua01mB2I+rTq0L3H6n4wT//5c/R9oP4vM8gT9yD6vE8F+H/T8fQpM+0T6o+0O0/0N+0D7f0z4o+sT1/0T/D/T/O8/3/6/v/0E/5M/k/+z/2//Q/7//g/4//R/2/+f/1/8D/l/wT8/30/1P/d/xT1z/yT/1/X/wP81v/F9Z/wX43/y/4b/Tf4x/6/3T4/+/x+Z+H6y/sP9r/9f/h/8D+w/1P/C/4r/Jv63/e/1b4d3L+kE6qJ9oR+A69M8H+T1z4qE/aH/k/0/1r3xP8j/+P9v/w//P/hP9//3f7z9Z/xPxL9n/P//f9z81n3d/3P/g/6L+H/5v9J/r/9n/wP9b/yf8V9w/t34f+rT3T/R/0X/f/3P4z+QTxz9aP2A+v//X+7/wT9mP43+A/hT5k9T/uX9O/oP7P9sP0D/3P7j+tP03/bPyP+A/+z8U/Qf8Z/9/137X7/X91/qL67/s/3/5N/f/5H7hP6P8b/kE+n1w+yL70P65/f/sP9x+4z6//f/8X9D/wL/o/5//oP65/6P51/aP9J/Q/2P4z99f6U9H/d/6j+uPxf7r/Y/3f+p/+f8X/r/438Z/0/y1/1T/x/2X+Fv7l+K/4h/Vn+4//H91P8L+K/2n/r/6v/aP9J/RfjH3m/3H9mP9A/wD/Gf51/eP4v/P/7b9D/sT9F/k/93+A/2v+O/7X8m/pf7v/S/1v+g/xL9tPxf/2f2D/A/97/T/5//Fv8D+Q/xv9e/Q/7N/tP9l/uX9b+GfxD+e/o37P/B/3n/x/6r/Ff53/S/7b+rP8P/M/9v/A/71/R/wf/1/2X+2Px3/N/8t/q/5/6E9X/9z95P3d/D/+1/d/yf+Xf6L/mP/F/Q/w/wX+g/0v9T99v//+Tf6d/Afxz9z/iP4//r/9n+Bf5N/f/87+X/5P9f/b34X+e/tf9l/+7/Ff+f+Q/l/8v/s/4X+I/t3+2vwv/1f1T+uP0//r/6X/Z/4//C/0r/p/+3/rP1A+oP8A/wv/kPxj9n/b35f/a/7/2//v//z+pP6//S//hP1k/eD6X9rP+D/z3+j/2//sPw//1n+r/yf/D/rT+x/1v8T/t3+P//r9G/iH/aPwA+9L+P/x/+D/yv/Tf5J/aH91/+L/0P6B+Xv4//0n/Gf6f9jfwP8T/m/93/hH/b//z/Xf6f/z/+c/xv+f/3T/Uf1z/7D9x/+H/sP+b/vP63/uP9f/5n9M/wf8m/z/3H9U/1v/h/w/9X/wD+Q/sP8p/i3+0f8t/U/73+Tf0P+FfwH+j/1T/s/+E/7n+M/6H/xP/S/13+1/65/5H9mH7v+8P5z/jT+vfyf/4/+Xv6V/9P/h/6d/S/+F/+T9c/4z/aP4r/t3+d/5n9E/q/+tfxr+Bv8v+a/4f/R3+H/6f7P+qvwz+m//z+xvwB+e33H+Pvz3/7v6v/Qv4v/T/2r/w/55+s/+B/p3/sP2v+Gf17+3P9D/wz/n/4X/tH+z/0T+1v4j+8/0b+1f6r/s39I/4T/oP9b/jH6r/8v6z+sX9b/7f/P/6x/2j/6v63/Ff1b/5P4V/R/+H/rf9c/mX77/uX/7v2d+L/wf9J/4r/Yf7/+/fxb+R/9b99f6v/x3+t/1P+Tf8n/D/4z/s/5n/w/+k/5f+Tf9A/6L/5n9n/+f79+Nn+t/uP7T/uP9r/7T/C/wD/2/2h/c/+P/qD9L/g/+R/8H+Pvw/6z+nfxn8A/0n/1f/n/zP/A/37/Fv5/9H/h/9r//H7w/oT81/y/+g/2D+M/4T/Bv/B/qD+RfxP+8/wv+c/xX+h//f7T//3+s//z9vfy39D/of8T/xf59/p/6r/mH/1v2l/f/85/rf8H/xf+5/wH+j//b9ePwL+rP5H/vT7v+GfxB+4P3f/Wf65/f//n9Yf+j+jfxA/nL9v/7H+9P5J+H/iP+m/xf+D/6P8N/6X/2f4X/zH/k/8D+dPxH+lP5/+R/tD/fP+g/9P6z/b32V/tH/gP/z/D/3//4v6b/b/3v+Gf67+L/2f/uPx//gP81/uX6N/03/E/+d/j3/l/0b/gH9D/g/+k/s7+u/yD8B/+r9aPy/9zP91/wf/xP2v/K/yT8N/kf9T/9P8/Pxn+X/7z+sH7r/s37xP0B+P/yP+R/+D8v/sH67+X/5r+p/8V+R/9b92/1f9K/v/59/j/8s/gH8L/pf97/1/+d/3H80/1H8D//b85/vv65+m/x7/d/xP/S39w/7P+s/0j/c/7T/K/1r/p/+9/5/+f/t/47/A/1H/hPx7/E/+j+Q/xP/A/wf+hP/Z/f/2X/o/65/U36B/g/9gP8//Gf1B+M/x/+ZPxB+2T7L/hL9n/0H+U/8J/Tf+A/w3+6PzT9mP//9F/sf6n/Vn83f6f9lP/D9237B/Pfx/99fy/+xP2j/Xn/n/Z3/h/zj+I/+j+qPwv+m/wf+c/7L9R/4P/D/xP9vfj78rPz79j/rT+s//n9fP6F/tP+S/7n+hPxv/U/4b/Jfy/+kPy//s37r/t/9w/vH79P1R/2r9q/vv8Qf//8G/fH5r+/X1E/2v7GfvN8gP//91//5+L/1j+J//l+Ff6R+vP+v+Yv/v91v8D/f/6T/0j+qfwx/Y392v+v+Z/x7/fH89/lH8A/45/p38hP+/9137H/+r8//9r8n/+X5r/l362PyF+0n5Xf2p/l3+Bf9H+23/1/yv/m38mP///2P/Gf+C/g==</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>
+AR+A7/jH//X4ZqHkEQbMv48/pP81n0EEM0O4j2DkIf0Q4zKio2V29y5k2A0RNDK8zF54e1/xQnIfI0+T6Xp/3E/c9vA43T2Z1z7zU3L5+Vb+d0xT3i1oB4x/3/KjDq/Y1yE4j1NfIq1o001zWwN+P1Qx24GjQk4/z8/Ua01mB2I+rTq0L3H6n4wT//5c/R9oP4vM8gT9yD6vE8F+H/T8fQpM+0T6o+0O0/0N+0D7f0z4o+sT1/0T/D/T/O8/3/6/v/0E/5M/k/+z/2//Q/7//g/4//R/2/+f/1/8D/l/wT8/30/1P/d/xT1z/yT/1/X/wP81v/F9Z/wX43/y/4b/Tf4x/6/3T4/+/x+Z+H6y/sP9r/9f/h/8D+w/1P/C/4r/Jv63/e/1b4d3L+kE6qJ9oR+A69M8H+T1z4qE/aH/k/0/1r3xP8j/+P9v/w//P/hP9//3f7z9Z/xPxL9n/P//f9z81n3d/3P/g/6L+H/5v9J/r/9n/wP9b/yf8V9w/t34f+rT3T/R/0X/f/3P4z+QTxz9aP2A+v//X+7/wT9mP43+A/hT5k9T/uX9O/oP7P9sP0D/3n7j+tP03/bPyP+A/+z8U/Qf8Z/9/137X7/X91/qL67/s/3/5N/f/5H7hP6P8b/kE+n1w+yL70P65/f/sP9x+4z6//f/8X9D/wL/o/5//oP65/6P51/aP9J/Q/2P4z99f6U9H/d/6j+uPxf7r/Y/3f+p/+f8X/r/438Z/0/y1/1T/x/2X+Fv7l+K/4h/Vn+4//H91P8L+K/2n/r/6v/aP9J/RfjH3m/3H9mP9A/wD/Gf51/eP4v/P/7b9D/sT9F/k/93+A/2v+O/7X8m/pf7v/S/1v+g/xL9tPxf/2f2D/A/97/T/5//Fv8D+Q/xv9e/Q/7N/tP9l/uX9b+GfxD+e/o37P/B/3n/x/6r/Ff53/S/7b+rP8P/M/9v/A/71/R/wf/1/2X+2Px3/N/8t/q/5/6E9X/9z95P3d/D/+1/d/yf+Xf6L/mP/F/Q/w/wX+g/0v9T99v//+Tf6d/Afxz9z/iP4//r/9n+Bf5N/f/87+X/5P9f/b34X+e/tf9l/+7/Ff+f+Q/l/8v/s/4X+I/t3+2vwv/1f1T+uP0//r/6X/Z/4//C/0r/p/+3/rP1A+oP8A/wv/kPxj9n/b35f/a/7/2//v//z+pP6//S//hP1k/eD6X9rP+D/z3+j/2//sPw//1n+r/yf/D/rT+x/1v8T/t3+P//r9G/iH/aPwA+9L+P/x/+D/yv/Tf5J/aH91/+L/0P6B+Xv4//0n/Gf6f9jfwP8T/m/93/hH/b//z/Xf6f/z/+c/xv+f/3T/Uf1z/7D9x/+H/sP+b/vP63/uP9f/5n9M/wf8m/z/3H9U/1v/h/w/9X/wD+Q/sP8p/i3+0f8t/U/73+Tf0P+FfwH+j/1T/s/+E/7n+M/6H/xP/S/13+1/65/5H9mH7v+8P5z/jT+vfyf/4/+Xv6V/9P/h/6d/S/+F/+T9c/4z/aP4r/t3+d/5n9E/q/+tfxr+Bv8v+a/4f/R3+H/6f7P+qvwz+m//z+xvwB+e33H+Pvz3/7v6v/Qv4v/T/2r/w/55+s/+B/p3/sP2v+Gf17+3P9D/wz/n/4X/tH+z/0T+1v4j+8/0b+1f6r/s39I/4T/oP9b/jH6r/8v6z+sX9b/7f/P/6x/2j/6v63/Ff1b/5P4V/R/+H/rf9c/mX77/uX/7v2d+L/wf9J/4r/Yf7/+/fxb+R/9b99f6v/x3+t/1P+Tf8n/D/4z/s/5n/w/+k/5f+Tf9A/6L/5n9n/+f79+Nn+t/uP7T/uP9r/7T/C/wD/2/2h/c/+P/qD9L/g/+R/8H+Pvw/6z+nfxn8A/0n/1f/n/zP/A/37/Fv5/9H/h/9r//H7w/oT81/y/+g/2D+M/4T/Bv/B/qD+RfxP+8/wv+c/xX+h//f7T//3+s//z9vfy39D/of8T/xf59/p/6r/mH/1v2l/f/85/rf8H/xf+5/wH+j//b9ePwL+rP5H/vT7v+GfxB+4P3f/Wf65/f//n9Yf+j+jfxA/nL9v/7H+9P5J+H/iP+m/xf+D/6P8N/6X/2f4X/zH/k/8D+dPxH+lP5/+R/tD/fP+g/9P6z/b32V/tH/gP/z/D/3//4v6b/b/3v+Gf67+L/2f/uPx//gP81/uX6N/03/E/+d/j3/l/0b/gH9D/g/+k/s7+u/yD8B/+r9aPy/9zP91/wf/xP2v/K/yT8N/kf9T/9P8/Pxn+X/7z+sH7r/s37xP0B+P/yP+R/+D8v/sH67+X/5r+p/8V+R/9b92/1f9K/v/59/j/8s/gH8L/pf97/1/+d/3H80/1H8D//b85/vv65+m/x7/d/xP/S39w/7P+s/0j/c/7T/K/1r/p/+9/5/+f/t/47/A/1H/hPx7/E/+j+Q/xP/A/wf+hP/Z/f/2X/o/65/U36B/g/9gP8//Gf1B+M/x/+ZPxB+2T7L/hL9n/0H+U/8J/Tf+A/w3+6PzT9mP//9F/sf6n/Vn83f6f9lP/D9237B/Pfx/99fy/+xP2j/Xn/n/Z3/h/zj+I/+j+qPwv+m/wf+c/7L9R/4P/D/xP9vfj78rPz79j/rT+s//n9fP6F/tP+S/7n+hPxv/U/4b/Jfy/+kPy//s37r/t/9w/vH79P1R/2r9q/vv8Qf//8G/fH5r+/X1E/2v7GfvN8gP//91//5+L/1j+J//l+Ff6R+vP+v+Yv/v91v8D/f/6T/0j+qfwx/Y392v+v+Z/x7/fH89/lH8A/45/p38hP+/9137H/+r8//9r8n/+X5r/l362PyF+0n5Xf2p/l3+Bf9H+23/1/yv/m38mP///2P/Gf+C/g==</item>
</string-array>
</resources>

View file

@ -850,6 +850,9 @@
<string name="label_font_size">Font Size</string> <string name="label_font_size">Font Size</string>
<string name="label_line_height">Line Height</string> <string name="label_line_height">Line Height</string>
<string name="label_paragraph_gap">Paragraph Gap</string> <string name="label_paragraph_gap">Paragraph Gap</string>
<string name="label_image_size">Image Size</string>
<string name="label_horizontal_margin">Horizontal Margin</string>
<string name="label_none">None</string>
<!-- Short label for the "Original" font option in the reader settings. "Orig" is an abbreviation. --> <!-- Short label for the "Original" font option in the reader settings. "Orig" is an abbreviation. -->
<string name="label_original">Orig</string> <string name="label_original">Orig</string>
<!-- Typographic sample label shown in the font size picker — "Aa" represents text size visually. Do not translate. --> <!-- Typographic sample label shown in the font size picker — "Aa" represents text size visually. Do not translate. -->

View file

@ -0,0 +1,20 @@
package com.aryan.reader.ml
import android.graphics.Bitmap
import timber.log.Timber
import java.io.File
class SpeechBubbleDetector(modelFile: File) : ISpeechBubbleDetector {
init {
Timber.i("OSS flavor: SpeechBubbleDetector stub initialized. ONNX features are disabled.")
}
override fun detectBubbles(bitmap: Bitmap, confidenceThreshold: Float): List<SpeechBubble> {
return emptyList()
}
override fun close() {
// No-op
}
}

View file

@ -0,0 +1,259 @@
package com.aryan.reader.ml
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.RectF
import androidx.core.graphics.scale
import timber.log.Timber
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Collections
import kotlin.math.min
class SpeechBubbleDetector(modelFile: File) : ISpeechBubbleDetector {
private var env: OrtEnvironment? = null
private var session: OrtSession? = null
private val inputSize = 504
private val byteBuffer = ByteBuffer.allocateDirect(3 * inputSize * inputSize * 4).order(
ByteOrder.nativeOrder())
private val floatBuffer = byteBuffer.asFloatBuffer()
private val pixels = IntArray(inputSize * inputSize)
init {
try {
env = OrtEnvironment.getEnvironment()
val options = OrtSession.SessionOptions().apply {
// Dynamically use available cores (cap at 4 to prevent thermal throttling)
val threadCount = Runtime.getRuntime().availableProcessors().coerceAtMost(4)
setIntraOpNumThreads(threadCount)
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT)
// 1. Thread spinning keeps CPU threads active between operations (reduces latency)
try {
addConfigEntry("session.intra_op.allow_spinning", "1")
} catch (_: Throwable) {
Timber.w("Could not set intra_op.allow_spinning config")
}
// 2. Enable XNNPACK (Highly optimized ARM CPU execution provider)
try {
// Safest cross-version way to request XNNPACK in Android ORT
addConfigEntry("session.disable_cpu_ep_fallback", "0")
addConfigEntry("optimization.enable_xnnpack", "1")
Timber.i("ONNX XNNPACK requested via config entry for optimized CPU inference.")
} catch (t: Throwable) {
Timber.w(t, "Could not set XNNPACK config entries")
}
}
session = env?.createSession(modelFile.absolutePath, options)
} catch (t: Throwable) {
Timber.e(t, "Fatal error initializing ONNX model")
}
}
// 3. Synchronized to safely share pre-allocated buffers across calls
@Synchronized
override fun detectBubbles(bitmap: Bitmap, confidenceThreshold: Float): List<SpeechBubble> {
Timber.tag("BubbleZoom").d("Detector: detectBubbles started. Bitmap: ${bitmap.width}x${bitmap.height}, threshold: $confidenceThreshold")
val currentEnv = env ?: run {
Timber.tag("BubbleZoom").w("Detector: OrtEnvironment is null")
return emptyList()
}
val currentSession = session ?: run {
Timber.tag("BubbleZoom").w("Detector: OrtSession is null")
return emptyList()
}
try {
// 4. Skip unnecessary scaling if already 504x504
val resized = if (bitmap.width == inputSize && bitmap.height == inputSize) {
bitmap
} else {
bitmap.scale(inputSize, inputSize, false)
}
floatBuffer.clear() // Reset buffer positions for reuse
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
val imageArea = inputSize * inputSize
for (i in 0 until imageArea) {
val pixel = pixels[i]
val r = ((pixel shr 16) and 0xFF) / 255.0f
val g = ((pixel shr 8) and 0xFF) / 255.0f
val b = (pixel and 0xFF) / 255.0f
floatBuffer.put(i, r)
floatBuffer.put(i + imageArea, g)
floatBuffer.put(i + 2 * imageArea, b)
}
floatBuffer.rewind() // Ready for tensor creation
val inputTensor = OnnxTensor.createTensor(currentEnv, floatBuffer, longArrayOf(1, 3, inputSize.toLong(), inputSize.toLong()))
val inputName = currentSession.inputNames.iterator().next()
Timber.tag("BubbleZoom").d("Detector: Running ONNX inference...")
val results = currentSession.run(Collections.singletonMap(inputName, inputTensor))
Timber.tag("BubbleZoom").d("Detector: ONNX inference finished.")
val parsedResults = mutableListOf<SpeechBubble>()
var detsOutput: FloatArray? = null
var labelsOutput: FloatArray? = null
var masksOutput: FloatArray? = null
var numBoxes = 0
var numClasses = 0
var maskH = 0
var maskW = 0
Timber.tag("ONNX_SEG").d("--- ONNX OUTPUT TENSORS ---")
results.forEach { entry ->
val value = entry.value as OnnxTensor
val shape = value.info.shape
Timber.tag("ONNX_SEG").d("Name: ${entry.key}, Shape: ${shape.contentToString()}")
if (entry.key.contains("dets") || entry.key.contains("boxes") || (shape.size == 3 && shape[2] == 4L)) {
numBoxes = shape[1].toInt()
val flatOutput = FloatArray(shape.reduce { acc, l -> acc * l }.toInt())
value.floatBuffer.get(flatOutput)
detsOutput = flatOutput
} else if (entry.key.contains("labels") || entry.key.contains("scores") || (shape.size == 3 && shape[2] != 4L && shape[1] == numBoxes.toLong())) {
numClasses = shape[2].toInt()
val flatOutput = FloatArray(shape.reduce { acc, l -> acc * l }.toInt())
value.floatBuffer.get(flatOutput)
labelsOutput = flatOutput
} else if (entry.key.contains("masks") || shape.size == 4) {
maskH = shape[2].toInt()
maskW = shape[3].toInt()
val flatOutput = FloatArray(shape.reduce { acc, l -> acc * l }.toInt())
value.floatBuffer.get(flatOutput)
masksOutput = flatOutput
}
}
Timber.tag("BubbleZoom").d("Detector: Outputs mapped. Boxes: $numBoxes, Classes: $numClasses, Masks: ${maskW}x${maskH}")
if (detsOutput != null && labelsOutput != null) {
var maxCoord = 0f
for (i in 0 until min(100, detsOutput.size)) {
if (detsOutput[i] > maxCoord) maxCoord = detsOutput[i]
}
val isNormalized = maxCoord <= 1.5f
val scaleX = if (isNormalized) bitmap.width.toFloat() else bitmap.width.toFloat() / inputSize
val scaleY = if (isNormalized) bitmap.height.toFloat() else bitmap.height.toFloat() / inputSize
for (i in 0 until numBoxes) {
var maxConf = 0f
for (c in 0 until numClasses) {
val conf = labelsOutput[i * numClasses + c]
if (conf > maxConf) maxConf = conf
}
if (maxConf > confidenceThreshold) {
val val0 = detsOutput[i * 4 + 0]
val val1 = detsOutput[i * 4 + 1]
val val2 = detsOutput[i * 4 + 2]
val val3 = detsOutput[i * 4 + 3]
val w = val2 * 1.08f
val h = val3 * 1.08f
val rawLeft = val0 - w / 2
val rawTop = val1 - h / 2
val rawRight = val0 + w / 2
val rawBottom = val1 + h / 2
val left = rawLeft * scaleX
val top = rawTop * scaleY
val right = rawRight * scaleX
val bottom = rawBottom * scaleY
var maskBitmap: Bitmap? = null
if (masksOutput != null && maskH > 0 && maskW > 0) {
try {
val maskScaleX = if (isNormalized) maskW.toFloat() else maskW.toFloat() / inputSize
val maskScaleY = if (isNormalized) maskH.toFloat() else maskH.toFloat() / inputSize
val mLeft = (rawLeft * maskScaleX).toInt().coerceIn(0, maskW - 1)
val mTop = (rawTop * maskScaleY).toInt().coerceIn(0, maskH - 1)
val mRight = (rawRight * maskScaleX).toInt().coerceIn(0, maskW - 1)
val mBottom = (rawBottom * maskScaleY).toInt().coerceIn(0, maskH - 1)
val cropW = mRight - mLeft
val cropH = mBottom - mTop
if (cropW > 0 && cropH > 0) {
val maskBmp = Bitmap.createBitmap(cropW, cropH, Bitmap.Config.ALPHA_8)
val maskPixels = IntArray(cropW * cropH)
val offset = i * maskW * maskH
// Dilate radius: expands the mask slightly to include outline
val dilationRadius = 1
for (y in 0 until cropH) {
for (x in 0 until cropW) {
val maskX = mLeft + x
val maskY = mTop + y
var isWhite = false
// Morphological Dilation: Check neighbors to expand & smooth the mask
for (dy in -dilationRadius..dilationRadius) {
for (dx in -dilationRadius..dilationRadius) {
val nx = (maskX + dx).coerceIn(0, maskW - 1)
val ny = (maskY + dy).coerceIn(0, maskH - 1)
val p = ny * maskW + nx
// > -0.5f captures slightly softer edge bounds
if (offset + p < masksOutput.size && masksOutput[offset + p] > -0.5f) {
isWhite = true
break
}
}
if (isWhite) break
}
maskPixels[y * cropW + x] = if (isWhite) Color.WHITE else Color.TRANSPARENT
}
}
maskBmp.setPixels(maskPixels, 0, cropW, 0, 0, cropW, cropH)
maskBitmap = maskBmp
}
} catch (e: Exception) {
Timber.tag("ONNX_SEG").e(e, "Failed to parse mask for bubble $i")
}
}
// Prevent adding invalid boxes
if (right > left && bottom > top) {
parsedResults.add(SpeechBubble(RectF(left, top, right, bottom), maskBitmap))
}
}
}
}
Timber.tag("BubbleZoom").d("Detector: Parsed ${parsedResults.size} valid bubbles above threshold.")
inputTensor.close()
results.close()
if (resized != bitmap) resized.recycle()
return parsedResults
} catch (t: Throwable) {
Timber.tag("BubbleZoom").e(t, "Detector: ONNX Inference failed completely")
}
return emptyList()
}
override fun close() {
session?.close()
session = null
env?.close()
env = null
}
}

View file

@ -0,0 +1,169 @@
package com.aryan.reader
import android.app.Application
import android.content.SharedPreferences
import android.content.res.Resources
import android.util.Log
import androidx.work.WorkManager
import com.aryan.reader.data.*
import com.tom_roush.pdfbox.android.PDFBoxResourceLoader
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var viewModel: MainViewModel
private lateinit var mockApplication: Application
private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
@Before
fun setup() {
mockkStatic(Log::class)
every { Log.isLoggable(any(), any()) } returns false
every { Log.d(any(), any()) } returns 0
every { Log.i(any(), any()) } returns 0
every { Log.e(any(), any(), any()) } returns 0
every { Log.w(any(), any<String>()) } returns 0
Dispatchers.setMain(testDispatcher)
mockApplication = mockk()
mockPrefs = mockk(relaxed = true)
mockEditor = mockk(relaxed = true)
val mockResources = mockk<Resources>(relaxed = true)
every { mockApplication.applicationContext } returns mockApplication
every { mockApplication.getSharedPreferences(any(), any()) } returns mockPrefs
every { mockApplication.resources } returns mockResources
every { mockPrefs.edit() } returns mockEditor
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean }
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
mockkStatic(AppDatabase::class)
val mockDb = mockk<AppDatabase>(relaxed = true)
every { AppDatabase.getDatabase(any()) } returns mockDb
mockkStatic(WorkManager::class)
every { WorkManager.getInstance(any()) } returns mockk(relaxed = true)
mockkStatic(PDFBoxResourceLoader::class)
every { PDFBoxResourceLoader.init(any()) } just Runs
mockkConstructor(AuthRepository::class)
mockkConstructor(RecentFilesRepository::class)
mockkConstructor(BillingClientWrapper::class)
mockkConstructor(RemoteConfigRepository::class)
mockkConstructor(FirestoreRepository::class)
mockkConstructor(FeedbackRepository::class)
mockkConstructor(FontsRepository::class)
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
every { anyConstructed<RecentFilesRepository>().getRecentFilesFlow() } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().activeShelvesFlow } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().shelfCrossRefsFlow } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().tagsFlow } returns flowOf(emptyList())
every { anyConstructed<RecentFilesRepository>().tagCrossRefsFlow } returns flowOf(emptyList())
coEvery { anyConstructed<RecentFilesRepository>().migrateLegacyShelvesToRoom() } just Runs
coEvery { anyConstructed<RecentFilesRepository>().seedTagsIfEmpty(any()) } just Runs
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
viewModel = MainViewModel(mockApplication)
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `search query updates uiState when search is active`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setSearchActive(true)
viewModel.onSearchQueryChange("Moby Dick")
assertEquals("Moby Dick", viewModel.uiState.value.searchQuery)
assertTrue(viewModel.uiState.value.isSearchActive)
}
@Test
fun `setSearchActive false clears the search query`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setSearchActive(true)
viewModel.onSearchQueryChange("Android")
viewModel.setSearchActive(false)
assertEquals("", viewModel.uiState.value.searchQuery)
assertFalse(viewModel.uiState.value.isSearchActive)
}
@Test
fun `switching theme updates internal state and preferences`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setAppThemeMode(AppThemeMode.DARK)
assertEquals(AppThemeMode.DARK, viewModel.uiState.value.appThemeMode)
verify { mockEditor.putString("app_theme_mode", AppThemeMode.DARK.name) }
}
@Test
fun `setTabsEnabled persists to shared preferences`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setTabsEnabled(true)
assertTrue(viewModel.uiState.value.isTabsEnabled)
verify { mockEditor.putBoolean("tabs_enabled", true) }
}
@Test
fun `banner message logic works correctly`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.showBanner("Test Message", isError = true)
val currentBanner = viewModel.uiState.value.bannerMessage
assertEquals("Test Message", currentBanner?.message)
assertTrue(currentBanner?.isError == true)
viewModel.bannerMessageShown()
assertEquals(null, viewModel.uiState.value.bannerMessage)
}
}