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"
minSdk = 26
targetSdk = 35
versionCode = 45
versionName = "1.0.45"
versionCode = 46
versionName = "1.0.46"
resourceConfigurations += setOf("en", "ar", "de", "tr")
@ -139,6 +139,17 @@ android {
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
dependencies {
@ -230,12 +241,12 @@ dependencies {
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")
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 {

Binary file not shown.

View file

@ -82,4 +82,9 @@
-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 {
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 {
@ -481,10 +481,18 @@
if (anchor) {
var href = anchor.getAttribute('href');
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 + "'");
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;
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);
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap) {
window.updateReaderStyles = function (fontSizeEm, lineHeight, fontFamily, textAlign, paragraphGap, imageSize, horizontalMargin) {
var logTag = "ReaderFontDiagnosis";
console.log(
logTag +
@ -517,11 +527,15 @@
lineHeight +
", Font: '" +
fontFamily +
"', Align: '" +
textAlign +
"', Gap: " +
paragraphGap
);
"', Align: '" +
textAlign +
"', Gap: " +
paragraphGap +
", ImageSize: " +
imageSize +
", HorizontalMargin: " +
horizontalMargin
);
var dynamicStyleId = "dynamicReaderStyles";
var dynamicStyleElement = document.getElementById(dynamicStyleId);
@ -535,10 +549,14 @@
var newFontSize = parseFloat(fontSizeEm);
var newLineHeight = parseFloat(lineHeight);
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(newLineHeight) || newLineHeight < 1.0 || newLineHeight > 3.0) newLineHeight = 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 = "";
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(
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
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);
return obj ? get_object_type_func(obj) : 0;
}
extern "C" JNIEXPORT jboolean JNICALL
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);
if (!obj) return JNI_FALSE;
@ -322,7 +326,9 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getPageObjectBoundingBox(JNIEnv *en
extern "C" JNIEXPORT jintArray JNICALL
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);
if (!obj || get_object_type_func(obj) != 3) return nullptr; // 3 = FPDF_PAGEOBJ_IMAGE
@ -605,4 +611,4 @@ Java_com_aryan_reader_pdf_NativePdfiumBridge_getLinkInfoAtPoint(JNIEnv *env, jcl
LOGI("PdfLinkDiagnostic: Link found but payload was empty or unsupported.");
return nullptr;
}
}

View file

@ -59,6 +59,16 @@ object AppDestinations {
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)
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
@Composable
@ -77,25 +87,21 @@ fun AppNavigation(
FileType.PDF, FileType.CBZ, FileType.CBR, FileType.CB7 -> {
if (uiState.selectedPdfUri != null) {
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE) {
popUpTo(AppDestinations.MAIN_ROUTE)
}
navController.navigateSingleTopTo(AppDestinations.PDF_VIEWER_ROUTE)
}
}
}
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML, FileType.FB2, FileType.DOCX, FileType.ODT, FileType.FODT -> {
if (uiState.selectedEpubBook != null) {
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
navController.navigate(AppDestinations.EPUB_READER_ROUTE) {
popUpTo(AppDestinations.MAIN_ROUTE)
}
navController.navigateSingleTopTo(AppDestinations.EPUB_READER_ROUTE)
}
}
}
null -> {
val currentRoute = navController.currentBackStackEntry?.destination?.route
if (currentRoute != null && currentRoute != AppDestinations.MAIN_ROUTE) {
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
navController.navigateSingleTopTo(AppDestinations.MAIN_ROUTE)
}
}
}
@ -286,4 +292,4 @@ fun AppNavigation(
)
}
}
}
}

View file

@ -185,6 +185,7 @@ import timber.log.Timber
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.Locale
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@ -1284,6 +1285,21 @@ fun DeviceVoicesTab(
.clickable(enabled = !isTtsActive && isBaseMode) {
savedVoiceName = 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) {
@ -1361,7 +1377,7 @@ fun DeviceVoicesTab(
enabled = !isTtsActive,
onClick = {
ttsEngine?.apply {
language = voice.locale
this.voice = voice
speak("This is a voice sample.", TextToSpeech.QUEUE_FLUSH, null, "sample_${voice.name}")
}
}
@ -3004,4 +3020,4 @@ fun ManageCacheTab(bookTitle: String, summaryCacheManager: SummaryCacheManager,
}
}
}
}
}

View file

@ -37,7 +37,10 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import androidx.core.content.edit
import com.aryan.reader.data.LocalSyncUtils
import com.aryan.reader.data.FolderBookMetadata
import java.io.File
import android.provider.DocumentsContract
import java.security.MessageDigest
class FolderSyncWorker(
private val appContext: Context,
@ -138,17 +141,17 @@ class FolderSyncWorker(
LocalSyncUtils.migrateLegacySidecarsToSubfolder(appContext, documentTree)
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...")
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree)
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree).toMutableMap()
folderMetadataMap.forEach { (bookId, remoteMeta) ->
val existingItem = recentFilesRepository.getFileByBookId(bookId)
if (existingItem != null) {
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(
lastChapterIndex = remoteMeta.lastChapterIndex,
lastPage = remoteMeta.lastPage,
@ -164,6 +167,8 @@ class FolderSyncWorker(
timestamp = if (remoteMeta.isRecent) remoteMeta.lastModifiedTimestamp else existingItem.timestamp
)
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 foundBookIds = mutableSetOf<String>()
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>()
dirQueue.add(rootDocId)
val projection = arrayOf(
android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID,
android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME,
android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE,
android.provider.DocumentsContract.Document.COLUMN_SIZE,
android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED
)
while (dirQueue.isNotEmpty()) {
if (isStopped) break
val currentDocId = dirQueue.removeFirst()
val childrenUri = android.provider.DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(folderUri, currentDocId)
try {
contentResolver.query(childrenUri, projection, null, null, null)?.use { cursor ->
val idCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_SIZE)
val modCol = cursor.getColumnIndexOrThrow(android.provider.DocumentsContract.Document.COLUMN_LAST_MODIFIED)
val idCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val modCol = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
while (cursor.moveToNext() && !isStopped) {
val docId = cursor.getString(idCol)
val name = cursor.getString(nameCol) ?: ""
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") {
dirQueue.add(docId)
}
@ -244,29 +249,49 @@ class FolderSyncWorker(
val type = getFileType(name, mimeType)
if (type != null && type in allowedFileTypes && !name.endsWith(".json") && !name.startsWith(".")) {
val stableId = "local_$name"
val stableId = buildStableBookId(name, rootDocId, docId)
foundBookIds.add(stableId)
val docUri = android.provider.DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
val docUri = DocumentsContract.buildDocumentUriUsingTree(folderUri, docId)
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) {
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) {
val oldId = oldItem.bookId
Timber.tag("FolderSync").i("Migrating book ID for $name from $oldId to $stableId")
recentFilesRepository.migrateBookIdLocally(oldId, stableId)
existingItem = recentFilesRepository.getFileByBookId(stableId)
try {
val syncDir = documentTree.findFile("EpistemeSyncData")
if (syncDir != null) {
syncDir.findFile(".$oldId.json")?.delete()
syncDir.findFile("$oldId.json")?.delete()
syncDir.findFile(".$oldId" + "_annotations.json")?.delete()
}
} catch (_: Exception) { Timber.tag("FolderSync").e("Failed to clean up orphaned SAF sidecars.") }
migrateFolderBookId(
folderUriString = folderUriString,
oldId = oldId,
newId = stableId,
folderMetadataMap = folderMetadataMap,
preloadedSidecars = preloadedSidecars,
existingItemsMap = existingItemsMap
)
existingItem = existingItemsMap[stableId]
}
}
@ -410,4 +435,78 @@ class FolderSyncWorker(
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
*/
// FontsScreen.kt
@file:Suppress("KotlinConstantConditions")
package com.aryan.reader
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -34,24 +38,32 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@ -59,6 +71,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
@ -69,6 +83,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.aryan.reader.data.CustomFontEntity
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FontsScreen(
viewModel: MainViewModel,
@ -76,24 +91,21 @@ fun FontsScreen(
) {
val fonts: List<CustomFontEntity> by viewModel.customFonts.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
// Dialog state
var showDeleteDialog by remember { mutableStateOf(false) }
var fontToDelete by remember { mutableStateOf<CustomFontEntity?>(null) }
var showGoogleFontsSheet by remember { mutableStateOf(false) }
val pickFontLauncher = rememberFilePickerLauncher { uris ->
uris.firstOrNull()?.let { viewModel.importFont(it) }
}
// Font mime types filter
val fontMimeTypes = arrayOf(
"font/ttf",
"font/otf",
"font/woff2",
"application/x-font-ttf",
"application/x-font-otf",
"application/font-woff2",
"application/vnd.ms-opentype",
"font/ttf", "font/otf", "font/woff2",
"application/x-font-ttf", "application/x-font-otf",
"application/font-woff2", "application/vnd.ms-opentype",
"application/x-font-opentype"
)
@ -111,11 +123,24 @@ fun FontsScreen(
},
floatingActionButton = {
if (fonts.isNotEmpty()) {
ExtendedFloatingActionButton(
onClick = { pickFontLauncher.launch(fontMimeTypes) },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text(stringResource(R.string.import_font)) }
)
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(
onClick = { pickFontLauncher.launch(fontMimeTypes) },
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text(stringResource(R.string.import_font)) }
)
}
}
}
) { padding ->
@ -125,7 +150,9 @@ fun FontsScreen(
title = stringResource(R.string.no_custom_fonts),
message = stringResource(R.string.import_fonts_desc),
onSelectFileClick = { pickFontLauncher.launch(fontMimeTypes) },
modifier = Modifier.fillMaxSize()
modifier = Modifier.fillMaxSize(),
secondaryButtonText = "Browse Google Fonts",
onSecondaryClick = { showGoogleFontsSheet = true }
)
} else {
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
fun FontListItem(
font: CustomFontEntity,

View file

@ -339,12 +339,17 @@ fun HomeScreen(
},
onAppThemeClick = { showAppThemePanel = true },
onTestPanelDetectionClick = { viewModel.testPanelDetection(context) },
onLanguageClick = { showLanguageDialog = true }
onTestSpeechBubbleDetectionClick = { viewModel.testSpeechBubbleDetection(context) },
onLanguageClick = { showLanguageDialog = true },
onExportLogsClick = { viewModel.exportLogsToFile(context) }
)
} else {
ContextualTopAppBar(
selectedItemCount = selectedContextItems.size,
onNavIconClick = { viewModel.clearContextualAction() },
onTagClick = {
viewModel.openTagSelection(selectedContextItems.map { it.bookId }.toSet())
},
onInfoClick = {
if (selectedContextItems.size == 1) {
itemForInfoDialog = selectedContextItems.first()
@ -472,7 +477,8 @@ fun HomeScreen(
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
}
},
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
@ -988,7 +994,9 @@ fun DefaultTopAppBar(
onStrictFilterToggleClick: () -> Unit,
onAppThemeClick: () -> Unit,
onTestPanelDetectionClick: () -> Unit,
onLanguageClick: () -> Unit
onTestSpeechBubbleDetectionClick: () -> Unit,
onLanguageClick: () -> Unit,
onExportLogsClick: () -> Unit
) {
var showOptionsMenu by remember { mutableStateOf(false) }
var showLimitMenu by remember { mutableStateOf(false) }
@ -1094,6 +1102,16 @@ fun DefaultTopAppBar(
onTestPanelDetectionClick()
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") {

View file

@ -64,6 +64,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
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.ArrowDropDown
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.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator
@ -131,6 +134,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.opds.OpdsAcquisition
import com.aryan.reader.opds.OpdsCatalog
import com.aryan.reader.opds.OpdsEntry
@ -159,7 +163,7 @@ fun LibraryScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val selectedItems = uiState.contextualActionItems
val isContextualModeActive = selectedItems.isNotEmpty()
val selectedShelves = uiState.contextualActionShelfNames
val selectedShelves = uiState.contextualActionShelfIds
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
val sortOrder = uiState.sortOrder
val shelves = uiState.shelves
@ -278,6 +282,7 @@ fun LibraryScreen(
selectedShelves = selectedShelves,
sortOrder = sortOrder,
libraryFilters = uiState.libraryFilters,
allTags = uiState.allTags,
pinnedLibraryBookIds = uiState.pinnedLibraryBookIds,
pagerState = pagerState,
scope = scope,
@ -289,6 +294,7 @@ fun LibraryScreen(
onFilterClick = { showFilterSheet = true },
onClearFilters = { viewModel.updateLibraryFilters(LibraryFilters()) },
onRemoveFilter = { viewModel.updateLibraryFilters(it) },
onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) },
onPinClick = { viewModel.togglePinForContextualItems(isHome = false) },
onClearSelection = { viewModel.clearContextualAction() },
onItemClick = viewModel::onRecentFileClicked,
@ -358,6 +364,7 @@ fun LibraryScreen(
if (showFilterSheet) {
LibraryFilterSheet(
filters = uiState.libraryFilters,
allTags = uiState.allTags,
syncedFolders = uiState.syncedFolders,
onApply = { viewModel.updateLibraryFilters(it) },
onDismiss = { showFilterSheet = false }
@ -385,7 +392,8 @@ fun LibraryScreen(
},
onUpdateName = { 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 selectedItems = uiState.contextualActionItems
val viewingShelfName = uiState.viewingShelfName
val viewingShelfId = uiState.viewingShelfId
val isAddingBooks = uiState.isAddingBooksToShelf
val shelves = uiState.shelves
val sortOrder = uiState.sortOrder
@ -414,17 +422,20 @@ fun ShelfScreen(
when {
selectedItems.isNotEmpty() -> viewModel.clearContextualAction()
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()) {
if (viewingShelfName != null && currentShelf != null) {
if (viewingShelfId != null && currentShelf != null) {
if (isAddingBooks) {
AddBooksModeScreen(
shelfName = viewingShelfName,
shelfName = currentShelf.name,
availableBooks = uiState.booksAvailableForAdding,
selectedBookUris = uiState.booksSelectedForAdding,
currentSource = uiState.addBooksSource,
@ -433,20 +444,23 @@ fun ShelfScreen(
onSourceChange = viewModel::setAddBooksSource,
onBookClick = { item -> viewModel.toggleBookSelectionForAdding(item.bookId) },
onBack = viewModel::dismissAddBooksToShelf,
onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfName) },
onAddSelectedBooks = { viewModel.addBooksToShelf(viewingShelfId) },
downloadingBookIds = uiState.downloadingBookIds
)
} else {
ShelfDetailScreen(
shelf = currentShelf,
childShelves = childShelves,
selectedItems = selectedItems,
sortOrder = sortOrder,
onSortOrderChange = viewModel::setSortOrder,
onBack = viewModel::unselectShelf,
onBack = viewModel::navigateBackFromShelf,
onAddBooksClick = viewModel::showAddBooksToShelf,
onChildShelfClick = viewModel::onShelfClick,
onBookClick = viewModel::onRecentFileClicked,
onBookLongClick = viewModel::onRecentItemLongPress,
onClearSelection = viewModel::clearContextualAction,
onTagClick = { viewModel.openTagSelection(selectedItems.map { it.bookId }.toSet()) },
onInfoClick = {
if (selectedItems.size == 1) {
itemForInfoDialog = selectedItems.first()
@ -454,24 +468,27 @@ fun ShelfScreen(
}
},
onDeleteClick = { showRemoveFromShelfDialog = true },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.name) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.name) },
onRenameShelf = { viewModel.showRenameShelfDialog(currentShelf.id) },
onDeleteShelf = { viewModel.showDeleteShelfDialog(currentShelf.id) },
downloadingBookIds = uiState.downloadingBookIds
)
}
}
if (showRenameDialogFor != null) {
RenameShelfDialog(
initialName = showRenameDialogFor,
onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) },
onDismiss = viewModel::dismissRenameShelfDialog
)
val shelfToRename = shelves.find { it.id == showRenameDialogFor }
if (shelfToRename != null) {
RenameShelfDialog(
initialName = shelfToRename.name,
onConfirm = { newName -> viewModel.renameShelf(showRenameDialogFor, newName) },
onDismiss = viewModel::dismissRenameShelfDialog
)
}
}
if (showDeleteDialogFor != null) {
DeleteShelfConfirmationDialog(
shelfName = showDeleteDialogFor,
shelfName = shelves.find { it.id == showDeleteDialogFor }?.name ?: "",
onConfirm = { viewModel.deleteShelf(showDeleteDialogFor) },
onDismiss = viewModel::dismissDeleteShelfDialog
)
@ -493,13 +510,9 @@ fun ShelfScreen(
if (showInfoDialog) {
FileInfoDialog(
item = item,
onDismiss = {
showInfoDialog = false
itemForInfoDialog = null
},
onUpdateName = { newName ->
viewModel.updateCustomName(item.bookId, newName)
}
onDismiss = { showInfoDialog = false; itemForInfoDialog = null },
onUpdateName = { newName -> viewModel.updateCustomName(item.bookId, newName) },
onOpenTags = { viewModel.openTagSelection(setOf(item.bookId)) }
)
}
}
@ -519,6 +532,7 @@ fun LibraryScreenContent(
selectedShelves: Set<String>,
sortOrder: SortOrder,
libraryFilters: LibraryFilters,
allTags: List<TagEntity>,
pinnedLibraryBookIds: Set<String>,
pagerState: PagerState,
scope: CoroutineScope,
@ -530,6 +544,7 @@ fun LibraryScreenContent(
onFilterClick: () -> Unit,
onClearFilters: () -> Unit,
onRemoveFilter: (LibraryFilters) -> Unit,
onTagClick: () -> Unit,
onPinClick: () -> Unit,
onClearSelection: () -> Unit,
onItemClick: (RecentFileItem) -> Unit,
@ -590,6 +605,7 @@ fun LibraryScreenContent(
ContextualTopAppBar(
selectedItemCount = selectedItems.size,
onNavIconClick = onClearSelection,
onTagClick = onTagClick,
onPinClick = onPinClick,
onInfoClick = onInfoClick,
onDeleteClick = onDeleteClick,
@ -734,6 +750,19 @@ fun LibraryScreenContent(
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,
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(
modifier = Modifier
.fillMaxSize(),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.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(
shelf = shelf,
isSelected = shelf.name in selectedShelves,
isSelected = shelf.id in selectedShelves,
onItemClick = { onShelfClick(shelf) },
onItemLongClick = { onShelfLongClick(shelf) }
)
@ -904,14 +977,17 @@ private fun CreateShelfDialog(onConfirm: (String) -> Unit, onDismiss: () -> Unit
@Composable
private fun ShelfDetailScreen(
shelf: Shelf,
childShelves: List<Shelf>,
selectedItems: Set<RecentFileItem>,
sortOrder: SortOrder,
onSortOrderChange: (SortOrder) -> Unit,
onBack: () -> Unit,
onAddBooksClick: () -> Unit,
onChildShelfClick: (Shelf) -> Unit,
onBookClick: (RecentFileItem) -> Unit,
onBookLongClick: (RecentFileItem) -> Unit,
onClearSelection: () -> Unit,
onTagClick: () -> Unit,
onInfoClick: () -> Unit,
onDeleteClick: () -> Unit,
onRenameShelf: () -> Unit,
@ -919,8 +995,71 @@ private fun ShelfDetailScreen(
downloadingBookIds: Set<String>,
) {
val isContextualModeActive = selectedItems.isNotEmpty()
val isFolderShelf = shelf.type == ShelfType.FOLDER
var showSortMenu 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(
modifier = Modifier,
@ -929,9 +1068,54 @@ private fun ShelfDetailScreen(
ContextualTopAppBar(
selectedItemCount = selectedItems.size,
onNavIconClick = onClearSelection,
onTagClick = onTagClick,
onInfoClick = onInfoClick,
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 {
CustomTopAppBar(
title = {
@ -942,7 +1126,14 @@ private fun ShelfDetailScreen(
overflow = TextOverflow.Ellipsis
)
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,
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 {
IconButton(onClick = { showMoreMenu = true }) {
Icon(
@ -1022,40 +1220,81 @@ private fun ShelfDetailScreen(
}
},
floatingActionButton = {
if (shelf.name != "Unshelved" && !isContextualModeActive) {
if (shelf.type == ShelfType.MANUAL && shelf.id != "unshelved" && !isContextualModeActive) {
ExtendedFloatingActionButton(
onClick = onAddBooksClick,
icon = { Icon(Icons.Default.Add, contentDescription = null) },
text = { Text(stringResource(R.string.fab_add_books)) }
)
}
}
) { paddingValues ->
if (shelf.books.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(stringResource(R.string.shelf_empty), style = MaterialTheme.typography.bodyLarge)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(shelf.books, key = { it.bookId }) { item ->
LibraryListItem(
item = item,
isSelected = selectedItems.any { it.bookId == item.bookId },
onItemClick = { onBookClick(item) },
onItemLongClick = { onBookLongClick(item) },
isDownloading = item.bookId in downloadingBookIds
},
content = { paddingValues ->
if (filteredChildShelves.isEmpty() && filteredDirectBooks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(
text = if (normalizedQuery.isBlank()) stringResource(R.string.shelf_empty) else stringResource(
R.string.no_results_found,
normalizedQuery
),
style = MaterialTheme.typography.bodyLarge
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
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(
item = item,
isSelected = selectedItems.any { it.bookId == item.bookId },
onItemClick = { onBookClick(item) },
onItemLongClick = { onBookLongClick(item) },
isDownloading = item.bookId in downloadingBookIds
)
}
}
}
}
}
)
}
@Composable
@ -1073,76 +1312,67 @@ private fun AddBooksModeScreen(
downloadingBookIds: Set<String>,
) {
var showSortMenu by remember { mutableStateOf(false) }
var showSourceMenu by remember { mutableStateOf(false) }
Scaffold(
modifier = Modifier,
topBar = {
CustomTopAppBar(
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
Box {
TextButton(onClick = { showSortMenu = true }) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = "Sort",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(sortOrder.displayName)
Column {
CustomTopAppBar(
title = { Text(stringResource(R.string.add_to_shelf, shelfName)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortOrder.entries.forEach { order ->
DropdownMenuItem(
text = { Text(order.displayName) },
onClick = {
onSortOrderChange(order)
showSortMenu = false
},
trailingIcon = {
if (order == sortOrder) {
Icon(Icons.Default.Check, contentDescription = "Selected")
}
}
},
actions = {
Box {
TextButton(onClick = { showSortMenu = true }) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = "Sort",
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(sortOrder.displayName)
}
DropdownMenu(
expanded = showSortMenu,
onDismissRequest = { showSortMenu = false }
) {
SortOrder.entries.forEach { order ->
DropdownMenuItem(
text = { Text(order.displayName) },
onClick = {
onSortOrderChange(order)
showSortMenu = false
},
trailingIcon = {
if (order == sortOrder) {
Icon(Icons.Default.Check, contentDescription = "Selected")
}
}
)
}
}
}
}
Box {
IconButton(onClick = { showSourceMenu = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More options")
}
DropdownMenu(
expanded = showSourceMenu,
onDismissRequest = { showSourceMenu = false }
) {
AddBooksSource.entries.forEach { source ->
DropdownMenuItem(
text = { Text(source.displayName) },
onClick = {
onSourceChange(source)
showSourceMenu = false
},
trailingIcon = {
if (source == currentSource) {
Icon(Icons.Default.Check, contentDescription = "Selected")
}
}
)
}
}
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
AddBooksSource.entries.forEach { source ->
FilterChip(
selected = source == currentSource,
onClick = { onSourceChange(source) },
label = { Text(source.displayName) }
)
}
}
)
}
},
floatingActionButton = {
if (selectedBookUris.isNotEmpty()) {
@ -1152,37 +1382,42 @@ private fun AddBooksModeScreen(
onClick = onAddSelectedBooks
)
}
}
) { paddingValues ->
if (availableBooks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(
text = if (currentSource == AddBooksSource.UNSHELVED) stringResource(R.string.no_unshelved_books) else stringResource(R.string.all_books_in_shelf),
style = MaterialTheme.typography.bodyLarge
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(availableBooks, key = { it.bookId }) { item ->
val isSelected = item.bookId in selectedBookUris
LibraryListItem(
item = item,
isSelected = isSelected,
onItemClick = { onBookClick(item) },
onItemLongClick = { onBookClick(item) },
isDownloading = item.bookId in downloadingBookIds
},
content = { paddingValues ->
if (availableBooks.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentAlignment = Alignment.Center
) {
Text(
text = if (currentSource == AddBooksSource.UNSHELVED) {
stringResource(R.string.no_unshelved_books)
} else {
stringResource(R.string.all_books_in_shelf)
},
style = MaterialTheme.typography.bodyLarge
)
}
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(paddingValues),
contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 88.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(availableBooks, key = { it.bookId }) { item ->
val isSelected = item.bookId in selectedBookUris
LibraryListItem(
item = item,
isSelected = isSelected,
onItemClick = { onBookClick(item) },
onItemLongClick = { onBookClick(item) },
isDownloading = item.bookId in downloadingBookIds
)
}
}
}
}
}
)
}
@Composable
@ -1260,7 +1495,10 @@ private fun ShelfListItem(
isSelected: Boolean,
onItemClick: () -> 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(
shape = MaterialTheme.shapes.large,
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
@ -1286,7 +1524,7 @@ private fun ShelfListItem(
)
) {
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
) {
ShelfCover(shelf = shelf)
@ -1294,13 +1532,29 @@ private fun ShelfListItem(
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = shelf.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
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 = shelf.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = getBookCountString(shelf.bookCount),
@ -1378,7 +1632,6 @@ private fun LibraryListItem(
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
)
if (isSelected) {
Box(
modifier = Modifier.matchParentSize().background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
@ -1432,57 +1685,60 @@ private fun LibraryListItem(
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FileTypeBadge(type = item.type, overlay = false)
Box(
modifier = Modifier
.weight(1f)
.height(28.dp),
contentAlignment = Alignment.CenterStart
) {
if (!item.isAvailable) {
Surface(
shape = RoundedCornerShape(50),
color = if (isDownloading) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
contentColor = if (isDownloading) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
if (item.tags.isNotEmpty()) {
BookTagChipsRow(
tags = item.tags,
compact = true,
modifier = Modifier.weight(1f, fill = false)
)
} else {
Spacer(modifier = Modifier.weight(1f))
}
if (!item.isAvailable) {
Surface(
shape = RoundedCornerShape(50),
color = if (isDownloading) {
MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.errorContainer
},
contentColor = if (isDownloading) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onErrorContainer
}
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (isDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Filled.Info,
contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(14.dp)
)
}
Text(
text = if (isDownloading) {
stringResource(R.string.status_downloading)
} else {
stringResource(R.string.not_available_locally)
},
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
if (isDownloading) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp
)
} else {
Icon(
Icons.Filled.Info,
contentDescription = stringResource(R.string.not_available_locally),
modifier = Modifier.size(14.dp)
)
}
Text(
text = if (isDownloading) {
stringResource(R.string.status_downloading)
} else {
stringResource(R.string.not_available_locally)
},
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium
)
}
}
}
@ -1558,7 +1814,7 @@ private fun DeleteShelfConfirmationDialog(
AlertDialog(
onDismissRequest = onDismiss,
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 = {
TextButton(onClick = onConfirm) { Text(stringResource(R.string.action_delete)) }
},
@ -1912,6 +2168,7 @@ private fun EditFolderFiltersDialog(
@Composable
fun LibraryFilterSheet(
filters: LibraryFilters,
allTags: List<TagEntity>,
syncedFolders: List<SyncedFolder>,
onApply: (LibraryFilters) -> 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) {
TextButton(onClick = { currentFilters = LibraryFilters() }) {
Text(stringResource(R.string.clear_all))

View file

@ -69,60 +69,77 @@ fun MainScreen(
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val viewingShelfName = uiState.viewingShelfName
val viewingShelfName = uiState.viewingShelfId
if (viewingShelfName != null) {
ShelfScreen(viewModel = viewModel)
} else {
val pagerState = rememberPagerState(
initialPage = uiState.mainScreenStartPage,
pageCount = { bottomBarItems.size }
)
val scope = rememberCoroutineScope()
androidx.compose.foundation.layout.Box(modifier = Modifier.fillMaxSize()) {
if (viewingShelfName != null) {
ShelfScreen(viewModel = viewModel)
} else {
val pagerState = rememberPagerState(
initialPage = uiState.mainScreenStartPage,
pageCount = { bottomBarItems.size }
)
val scope = rememberCoroutineScope()
LaunchedEffect(uiState.mainScreenStartPage) {
if (pagerState.currentPage != uiState.mainScreenStartPage) {
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
LaunchedEffect(uiState.mainScreenStartPage) {
if (pagerState.currentPage != uiState.mainScreenStartPage) {
pagerState.animateScrollToPage(uiState.mainScreenStartPage)
}
}
}
LaunchedEffect(pagerState.currentPage) {
viewModel.setMainScreenPage(pagerState.currentPage)
}
LaunchedEffect(pagerState.currentPage) {
viewModel.setMainScreenPage(pagerState.currentPage)
}
Scaffold(
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
bottomBar = {
NavigationBar {
bottomBarItems.forEachIndexed { index, screen ->
NavigationBarItem(
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
label = { Text(stringResource(screen.stringResId)) },
selected = pagerState.currentPage == index,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } }
Scaffold(
contentWindowInsets = androidx.compose.foundation.layout.WindowInsets(0, 0, 0, 0),
bottomBar = {
NavigationBar {
bottomBarItems.forEachIndexed { index, screen ->
NavigationBarItem(
icon = { Icon(painterResource(id = screen.iconResId), contentDescription = stringResource(screen.stringResId)) },
label = { Text(stringResource(screen.stringResId)) },
selected = pagerState.currentPage == index,
onClick = { scope.launch { pagerState.animateScrollToPage(index) } }
)
}
}
}
) { innerPadding ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
key = { bottomBarItems[it].route },
beyondViewportPageCount = 1,
userScrollEnabled = false
) { page ->
when (page) {
0 -> HomeScreen(
viewModel = viewModel,
windowSizeClass = windowSizeClass,
navController = navController
)
1 -> LibraryScreen(viewModel = viewModel)
}
}
}
) { innerPadding ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
key = { bottomBarItems[it].route },
beyondViewportPageCount = 1,
userScrollEnabled = false
) { page ->
when (page) {
0 -> HomeScreen(
viewModel = viewModel,
windowSizeClass = windowSizeClass,
navController = navController
)
1 -> LibraryScreen(viewModel = viewModel)
}
}
}
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 com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.ImportedFileCache
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import io.legere.pdfiumandroid.PdfiumCore
@ -55,6 +56,13 @@ class MetadataExtractionWorker(
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 {
val uri = item.uriString?.toUri() ?: return@forEach
val type = item.type
@ -86,7 +94,8 @@ class MetadataExtractionWorker(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false
parseContent = false,
extractionDirOverride = tempExtractionDir
)
title = book.title.takeIf { it.isNotBlank() && it != "content" }
author = book.author.takeIf { it.isNotBlank() && !it.equals("Unknown", ignoreCase = true) }
@ -97,7 +106,8 @@ class MetadataExtractionWorker(
inputStream = inputStream,
bookId = item.bookId,
originalBookNameHint = item.displayName,
parseContent = false
parseContent = false,
extractionDirOverride = tempExtractionDir
)
book?.let {
title = it.title.takeIf { t -> t.isNotBlank() && t != "content" }
@ -139,7 +149,8 @@ class MetadataExtractionWorker(
bookId = item.bookId,
originalBookNameHint = item.displayName,
isFlat = type == FileType.FODT,
parseContent = false
parseContent = false,
extractionDirOverride = tempExtractionDir
)
title = book.title.takeIf { it.isNotBlank() && it != "content" }
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}")
} finally {
try {
val cacheDir = File(appContext.cacheDir, "imported_file_${item.bookId}")
if (cacheDir.exists()) {
val deleted = cacheDir.deleteRecursively()
if (deleted) Timber.tag("MetadataWorker").d("Cleaned up extraction cache for ${item.bookId}")
if (tempExtractionDir?.exists() == true) {
val deleted = tempExtractionDir.deleteRecursively()
if (deleted) {
Timber.tag("MetadataWorker")
.d("Cleaned up temporary extraction cache for ${item.bookId}")
}
}
} 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}")
}
}
}
@ -183,4 +197,4 @@ class MetadataExtractionWorker(
return@withContext Result.failure()
}
}
}
}

View file

@ -33,7 +33,19 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
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.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -90,7 +102,6 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
@ -220,6 +231,7 @@ fun ContextualTopAppBar(
selectedItemCount: Int,
onNavIconClick: () -> Unit,
onInfoClick: (() -> Unit)? = null,
onTagClick: (() -> Unit)? = null,
onSelectAllClick: (() -> Unit)? = null,
onPinClick: (() -> Unit)? = null,
onDeleteClick: () -> Unit
@ -232,6 +244,11 @@ fun ContextualTopAppBar(
}
},
actions = {
if (onTagClick != null) {
IconButton(onClick = onTagClick) {
Icon(painterResource(id = R.drawable.tag), contentDescription = "Tag")
}
}
if (onPinClick != null) {
IconButton(onClick = onPinClick) {
Icon(Icons.Filled.PushPin, contentDescription = stringResource(R.string.pin_unpin))
@ -342,7 +359,7 @@ fun DeleteConfirmationDialog(
}
@Composable
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit) {
fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (String?) -> Unit, onOpenTags: () -> Unit) {
LocalContext.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 {
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.size), formatFileSize(item.fileSize))
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(
modifier = Modifier
.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"
fun RecentFileItem.cardTitle(): String {
@ -1046,4 +1131,92 @@ fun ReadingProgressSection(
trackColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.4f)
)
}
}
}
@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.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)
abstract class AppDatabase : RoomDatabase() {
abstract fun recentFileDao(): RecentFileDao
abstract fun customFontDao(): CustomFontDao
abstract fun shelfDao(): ShelfDao
abstract fun tagDao(): TagDao
companion object {
@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 {
return INSTANCE ?: synchronized(this) {
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_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
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)
.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 syncFileName = ".${metadata.bookId}.json"
val legacyVisibleName = "${metadata.bookId}.json"
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}.")
return@withContext
}
}
} catch (_: Exception) {}
}
if (existingVisible != null && existingVisible.exists()) {
try { existingVisible.delete() } catch (_: Exception) {}
val existingMeta = resolveAndCleanMetadataConflicts(context, syncDir, metadata.bookId)
if (existingMeta != null && existingMeta.lastModifiedTimestamp > metadata.lastModifiedTimestamp) {
Timber.tag(TAG).w("ClobberCheck: ABORTING save. Folder has newer data for ${metadata.bookId}.")
return@withContext
}
val tempFileName = ".${metadata.bookId}.tmp"
syncDir.findFile(tempFileName)?.delete()
val tempFile = syncDir.createFile("application/json", tempFileName)
if (tempFile == null) {
Timber.tag(TAG).e("Could not create temp metadata file for ${metadata.bookId}")
@ -128,7 +108,7 @@ object LocalSyncUtils {
}
@Suppress("KotlinConstantConditions") if (writeSuccess) {
val targetFile = rootTree.findFile(syncFileName)
val targetFile = syncDir.findFile(syncFileName)
if (targetFile != null && targetFile.exists()) {
targetFile.delete()
}
@ -169,8 +149,8 @@ object LocalSyncUtils {
val currentBest = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
val targetName = ".${bookId}${ANNOTATION_SUFFIX}.json"
val tempName = ".${bookId}${ANNOTATION_SUFFIX}.tmp"
syncDir.findFile(tempName)?.delete()
val tempFile = syncDir.createFile("application/json", tempName)
val existingMain = syncDir.findFile(targetName)
if (currentBest != null) {
val (remoteTs, _) = currentBest
@ -186,8 +166,6 @@ object LocalSyncUtils {
wrapper.put("data", JSONObject(jsonPayload))
val contentBytes = wrapper.toString().toByteArray()
syncDir.findFile(tempName)?.delete()
if (tempFile == null) {
Timber.tag("FolderAnnotationSync").e("Failed to create temp sidecar file.")
return@withContext
@ -210,7 +188,7 @@ object LocalSyncUtils {
}
@Suppress("KotlinConstantConditions") if (writeSuccess) {
val existingMain = rootTree.findFile(targetName)
val existingMain = syncDir.findFile(targetName)
if (existingMain != null) {
if (!existingMain.delete()) {
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 {
val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME)
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 ->
val name = file.name ?: ""
name.contains(ANNOTATION_SUFFIX) && name.endsWith(".json") && !name.endsWith(".tmp")
}
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)
}
for (bookId in bookIds) {
val best = resolveAndCleanAnnotationConflicts(context, syncDir, bookId)
if (best != null) {
results[bookId] = best
}
}
} catch (e: Exception) {
@ -319,15 +256,16 @@ object LocalSyncUtils {
bookId: String
): Pair<Long, String>? {
val basePattern = ".${bookId}${ANNOTATION_SUFFIX}"
val legacyPattern = "${bookId}${ANNOTATION_SUFFIX}"
val allFiles = syncDir.listFiles()
val candidates = allFiles.filter { file ->
val name = file.name ?: ""
name.startsWith(basePattern) &&
name.endsWith(".json") &&
!name.endsWith(".tmp") &&
!name.contains(".syncthing.")
(name.startsWith(basePattern) || name.startsWith(legacyPattern)) &&
name.endsWith(".json") &&
!name.endsWith(".tmp") &&
!name.contains(".syncthing.")
}
if (candidates.isEmpty()) return null
@ -460,16 +398,76 @@ object LocalSyncUtils {
}
}
// 3. Migrate Legacy to Hidden if needed
val winnerName = bestFile.name ?: ""
if (!winnerName.startsWith(".")) {
Timber.tag(TAG).i("Migrating legacy file to hidden: $winnerName")
val correctName = ".${bookId}.json"
if (bestFile.name != correctName) {
Timber.tag(TAG).i("Renaming metadata winner ${bestFile.name} to $correctName")
bestFile.renameTo(correctName)
}
}
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(
context: Context,
sourceFolderUri: Uri
@ -486,6 +484,7 @@ object LocalSyncUtils {
.filter {
val name = it.name ?: ""
(name.endsWith(".json") || name.contains(".sync-conflict")) &&
!name.contains(ANNOTATION_SUFFIX) &&
!name.endsWith(".tmp") &&
!name.contains(".syncthing.")
}
@ -513,4 +512,4 @@ object LocalSyncUtils {
}
return@withContext finalResults
}
}
}

View file

@ -21,20 +21,19 @@
package com.aryan.reader.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Upsert
import kotlinx.coroutines.flow.Flow
@Dao
interface RecentFileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Upsert
suspend fun insertOrUpdateFile(file: RecentFileEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
@Upsert
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>>
@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")
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>
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
@ -99,4 +98,4 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET highlights = :highlightsJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateHighlights(bookId: String, highlightsJson: String, timestamp: Long)
}
}

View file

@ -51,7 +51,10 @@ data class RecentFileEntity(
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
@ColumnInfo(defaultValue = "NULL") val customName: 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(
@ -76,5 +79,8 @@ data class RecentFileSummary(
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?,
@ColumnInfo(defaultValue = "0") val isReflowPreferred: Boolean,
@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 customName: 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()
}
@ -77,7 +81,10 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
isReflowPreferred = this.isReflowPreferred,
customName = this.customName,
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,
customName = this.customName,
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,
customName = this.customName,
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.paginatedreader.Locator
import com.aryan.reader.pdf.PdfRichTextRepository
import com.aryan.reader.epub.ImportedFileCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@ -39,6 +40,8 @@ import com.aryan.reader.pdf.data.PageLayoutRepository
import com.aryan.reader.pdf.data.PdfTextBoxRepository
import org.json.JSONObject
import org.json.JSONArray
import java.util.UUID
import androidx.core.content.edit
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 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 {
if (!coverCacheDir.exists()) {
coverCacheDir.mkdirs()
@ -74,6 +82,28 @@ class RecentFilesRepository(private val context: Context) {
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) {
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() }
}
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) {
recentFileDao.clearAll()
if (coverCacheDir.exists()) {
@ -131,7 +181,10 @@ class RecentFilesRepository(private val context: Context) {
isDeleted = item.isDeleted,
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
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 {
item.toRecentFileEntity()
@ -348,7 +401,9 @@ class RecentFilesRepository(private val context: Context) {
if (item != null) {
val currentTime = System.currentTimeMillis()
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
Timber.d("Updated PDF reading position for ${item.bookId} to page $page, progress $progress%")
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()
pdfHighlightRepository.getFileForSync(item.bookId).delete()
val cacheDir = File(context.cacheDir, "imported_file_${item.bookId}")
if (cacheDir.exists()) cacheDir.deleteRecursively()
ImportedFileCache.clearBookCache(context, item.bookId)
} catch (e: Exception) {
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(pdfHighlightRepository.getFileForSync(oldId), pdfHighlightRepository.getFileForSync(newId))
val oldCache = File(context.cacheDir, "imported_file_$oldId")
val newCache = File(context.cacheDir, "imported_file_$newId")
ImportedFileCache.clearTemporaryBookDirs(context, oldId)
ImportedFileCache.clearTemporaryBookDirs(context, newId)
val oldCache = ImportedFileCache.activeBookDir(context, oldId)
val newCache = ImportedFileCache.activeBookDir(context, newId)
if (oldCache.exists()) {
if (newCache.exists()) newCache.deleteRecursively()
oldCache.renameTo(newCache)
@ -486,8 +542,7 @@ class RecentFilesRepository(private val context: Context) {
try {
pdfRichTextRepository.getFileForSync(bookId).delete()
pageLayoutRepository.getLayoutFile(bookId).delete()
val cacheDir = File(context.cacheDir, "imported_file_$bookId")
if (cacheDir.exists()) cacheDir.deleteRecursively()
ImportedFileCache.clearBookCache(context, bookId)
Timber.d("Cleared layout and text caches for modified book: $bookId")
} catch (e: Exception) {
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.")
}
}
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 css: Map<String, String> = emptyMap(),
@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,
shouldUseToc: Boolean = true,
originalBookNameHint: String = "streamed_book",
parseContent: Boolean = true
parseContent: Boolean = true,
extractionDirOverride: File? = null
): EpubBook {
return withContext(Dispatchers.IO) {
Timber.d("Parsing EPUB input stream for bookId: $bookId")
val extractionDir = File(context.cacheDir, "imported_file_$bookId")
if (extractionDir.exists()) {
extractionDir.deleteRecursively()
}
extractionDir.mkdirs()
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
val tempFile = File.createTempFile("epub_stream", ".epub", context.cacheDir)
val filesMap: Map<String, EpubFile>
@ -244,6 +241,24 @@ class EpubParser(private val context: Context) {
val metadataLanguage =
document.metadata.selectFirstChildTag("dc:language")?.textContent ?: "en"
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 opfParentDir = File(opfRelativePath).parentFile ?: File("")
val manifestItems = getManifestItems(document.manifest, opfParentDir)
@ -344,7 +359,10 @@ class EpubParser(private val context: Context) {
pageList = pageTargets,
tableOfContents = tableOfContents,
extractionBasePath = extractionBasePath,
css = cssContent
css = cssContent,
seriesName = metadataSeriesName,
seriesIndex = metadataSeriesIndex,
description = metadataDescription
)
}
@ -688,4 +706,4 @@ class EpubParser(private val context: Context) {
lowerName.endsWith(".htm") ||
lowerName.endsWith(".css")
}
}
}

View file

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

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?
companion object {
const val EXTRACTED_EPUB_DIR_NAME = "extracted_epubs"
init {
System.loadLibrary("mobi")
System.loadLibrary("native-lib")
}
}
private fun getBookExtractionDir(bookIdentifier: String): File {
val parentDir = File(context.cacheDir, EXTRACTED_EPUB_DIR_NAME)
if (!parentDir.exists()) {
parentDir.mkdirs()
}
return File(parentDir, bookIdentifier)
}
suspend fun createMobiBook(
inputStream: InputStream,
bookId: String,
originalBookNameHint: String,
parseContent: Boolean = true
parseContent: Boolean = true,
extractionDirOverride: File? = null
): EpubBook? = withContext(Dispatchers.IO) {
val tempFile = File.createTempFile("temp_mobi_", ".mobi", context.cacheDir)
try {
@ -162,8 +153,8 @@ class MobiParser(private val context: Context) {
val bookTitle = parsedData.title ?: originalBookNameHint
val bookAuthor = parsedData.author ?: "Unknown Author"
val extractionDir = File(context.cacheDir, "imported_file_$bookId")
extractionDir.mkdirs()
val extractionDir = extractionDirOverride?.let(ImportedFileCache::prepareDirectory)
?: ImportedFileCache.prepareActiveBookDir(context, bookId)
val sequentialImageMap = parsedData.resources
.filter { it.mediaType.startsWith("image/") }
@ -314,4 +305,4 @@ class MobiParser(private val context: Context) {
Timber.d("Final EpubBook created. CSS map size: ${finalBook.css.size}, Image count: ${finalBook.images.size}")
return@withContext finalBook
}
}
}

View file

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

View file

@ -41,11 +41,17 @@ import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.util.UUID
import java.util.zip.ZipFile
class SingleFileImporter(private val context: Context) {
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(
inputStream: InputStream,
type: FileType,
@ -104,14 +110,19 @@ class SingleFileImporter(private val context: Context) {
var inQuotes = false
for (char in line) {
if (char == '\"') {
inQuotes = !inQuotes
} else if (char == delimiter && !inQuotes) {
val escaped = current.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
writer.write("<td>$escaped</td>")
current.clear()
} else {
current.append(char)
when (char) {
'\"' -> {
inQuotes = !inQuotes
}
delimiter if !inQuotes -> {
val escaped =
current.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
writer.write("<td>$escaped</td>")
current.clear()
}
else -> {
current.append(char)
}
}
}
val escapedFinal = current.toString().replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
@ -663,15 +674,29 @@ class SingleFileImporter(private val context: Context) {
val parseStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx START | file=$originalBookNameHint")
val htmlContent = inputStream.use { stream ->
val converter = DocumentConverter()
converter.convertToHtml(stream).value ?: ""
}
Timber.tag("FileOpenPerf").d("[DOCX] parseDocx: mammoth conversion done | elapsed=${System.currentTimeMillis() - parseStart}ms")
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()
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")
FileOutputStream(tempFile).bufferedWriter().use { writer ->
val title = originalBookNameHint.substringBeforeLast(".")
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)
}
} finally {
if (sourceDocxFile.exists()) {
sourceDocxFile.delete()
}
if (tempFile.exists()) {
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(
extractionDir: File,
bookId: String,
@ -718,4 +772,4 @@ class SingleFileImporter(private val context: Context) {
isInToc = true
)
}
}
}

View file

@ -92,6 +92,8 @@ import java.io.BufferedReader
import java.io.ByteArrayOutputStream
import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
private fun getFontCssInjection(): String {
return """
@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")
@Composable
fun ChapterWebView(
@ -336,6 +349,8 @@ fun ChapterWebView(
currentFontSize: Float,
currentLineHeight: Float,
currentParagraphGap: Float,
currentImageSize: Float,
currentHorizontalMargin: Float,
onChapterInitiallyScrolled: () -> Unit,
modifier: Modifier = Modifier,
onTap: () -> Unit,
@ -370,6 +385,7 @@ fun ChapterWebView(
onAutoScrollChapterEnd: () -> Unit = {},
activeHighlightPalette: List<HighlightColor>,
onUpdatePalette: (Int, HighlightColor) -> Unit,
onInternalLinkClick: (String) -> Unit,
activeTextureId: String? = null
) {
Timber.d(
@ -471,6 +487,8 @@ fun ChapterWebView(
currentFontSize,
currentLineHeight,
currentParagraphGap,
currentImageSize,
currentHorizontalMargin,
currentFontFamily,
currentTextAlign
) {
@ -550,6 +568,11 @@ fun ChapterWebView(
consoleMessage?.let {
val message = it.message()
when {
message.startsWith("LINK_NAV:") -> {
Timber.tag(TAG_LINK_NAV)
.d("JS -> ${message.substringAfter("LINK_NAV: ")}")
}
message.startsWith("FootnoteDiag:") -> {
Timber.tag("FootnoteDiag")
.d("JS -> ${message.substringAfter("FootnoteDiag: ")}")
@ -653,16 +676,31 @@ fun ChapterWebView(
}, "FootnoteBridge"
)
addJavascriptInterface(
LinkNavJsBridge(chapterTitle), "LinkNavBridge"
)
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(
view: WebView?, request: WebResourceRequest?
): Boolean {
val url = request?.url?.toString()
if (url != null && (url.startsWith("http://") || url.startsWith("https://"))) {
Timber.d("Intercepted external link: $url")
Timber.tag(TAG_LINK_NAV)
.d("[EXTERNAL-INTERCEPT] url='$url' from chapter '$chapterTitle'")
showExternalLinkDialog = url
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
}
@ -744,7 +782,7 @@ fun ChapterWebView(
}
view?.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);",
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
null
)
@ -768,10 +806,59 @@ fun ChapterWebView(
}
} else if (!initialFragmentId.isNullOrBlank()) {
Timber.tag("NavDiag").d("WebView onPageFinished: Scrolling to Element ID: $initialFragmentId")
view?.evaluateJavascript(
"javascript:var el = document.getElementById('$initialFragmentId'); if(el) { el.scrollIntoView(); } else { console.log('Element not found: $initialFragmentId'); }",
null
)
val js = """
(function() {
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()
scrollActionTaken = true
} else if (initialScrollTarget != null) {
@ -859,7 +946,7 @@ fun ChapterWebView(
)
webView.evaluateJavascript(
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap);",
"javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin);",
null
)
@ -1075,4 +1162,4 @@ fun ChapterWebView(
})
}
}
}
}

View file

@ -1378,6 +1378,7 @@ fun TtsOverlayControls(
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
isCollapsed: Boolean,
onCollapseChange: (Boolean) -> Unit,
onLocateCurrentChunk: () -> Unit,
onOpenTtsSettings: () -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier,
@ -1504,6 +1505,14 @@ fun TtsOverlayControls(
}
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)) {
Icon(Icons.Default.ChevronRight, "Collapse", modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
}
@ -1589,4 +1598,4 @@ fun TtsOverlayControls(
}
}
}
}
}

View file

@ -24,17 +24,7 @@ import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.animation.animateContentSize
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.layout.Arrangement
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.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.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.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.Button
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Slider
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import com.aryan.reader.R
import com.aryan.reader.data.CustomFontEntity
import java.io.File
import androidx.compose.material3.Switch
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
import kotlin.math.roundToInt
const val SETTINGS_PREFS_NAME = "epub_reader_settings"
private const val TEXT_ALIGN_KEY = "reader_text_align"
private const val FONT_SIZE_KEY = "reader_font_size"
private const val LINE_HEIGHT_KEY = "reader_line_height"
private const val 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 FONT_FAMILY_KEY = "reader_font_family"
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_LINE_HEIGHT_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_PITCH_KEY = "tts_pitch"
@ -170,6 +181,8 @@ data class FormatSettings(
val fontSize: Float,
val lineHeight: Float,
val paragraphGap: Float,
val imageSize: Float,
val horizontalMargin: Float,
val font: ReaderFont,
val customPath: String?,
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_LINE_HEIGHT_PREFIX = "local_line_height_"
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_TEXT_ALIGN_PREFIX = "local_text_align_"
private const val HORIZONTAL_MARGIN_KEY = "reader_horizontal_margin"
fun loadFormatIsLocal(context: Context, bookId: String): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
@ -198,6 +214,8 @@ fun saveLocalReaderSettings(
fontSize: Float,
lineHeight: Float,
paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
@ -207,6 +225,8 @@ fun saveLocalReaderSettings(
putFloat(LOCAL_FONT_SIZE_PREFIX + bookId, fontSize)
putFloat(LOCAL_LINE_HEIGHT_PREFIX + bookId, lineHeight)
putFloat(LOCAL_PARAGRAPH_GAP_PREFIX + bookId, paragraphGap)
putFloat(LOCAL_IMAGE_SIZE_PREFIX + bookId, imageSize)
putFloat(LOCAL_HORIZONTAL_MARGIN_PREFIX + bookId, horizontalMargin)
if (customFontPath != null) {
putString(LOCAL_FONT_FAMILY_PREFIX + bookId, "custom|$customFontPath")
} else {
@ -260,6 +280,14 @@ fun loadPullToTurnMultiplier(context: Context): Float {
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 {
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)
}
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)) {
prefs.getString(LOCAL_FONT_FAMILY_PREFIX + bookId, ReaderFont.ORIGINAL.id) ?: ReaderFont.ORIGINAL.id
} else {
@ -300,7 +340,16 @@ fun loadFormatSettings(context: Context, bookId: String, isLocal: Boolean): Form
}
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(
@ -339,6 +388,8 @@ fun saveReaderSettings(
fontSize: Float,
lineHeight: Float,
paragraphGap: Float,
imageSize: Float,
horizontalMargin: Float,
fontFamily: ReaderFont,
customFontPath: String?,
textAlign: ReaderTextAlign
@ -348,6 +399,8 @@ fun saveReaderSettings(
putFloat(FONT_SIZE_KEY, fontSize)
putFloat(LINE_HEIGHT_KEY, lineHeight)
putFloat(PARAGRAPH_GAP_KEY, paragraphGap)
putFloat(IMAGE_SIZE_KEY, imageSize)
putFloat(HORIZONTAL_MARGIN_KEY, horizontalMargin)
if (customFontPath != null) {
putString(FONT_FAMILY_KEY, "custom|$customFontPath")
} else {
@ -387,6 +440,7 @@ fun loadVolumeScrollSetting(context: Context): Boolean {
return prefs.getBoolean(VOLUME_SCROLL_ENABLED_KEY, false)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ReaderTextFormatPanel(
isVisible: Boolean,
@ -394,8 +448,12 @@ fun ReaderTextFormatPanel(
onFontSizeChange: (Float) -> Unit,
currentLineHeight: Float,
onLineHeightChange: (Float) -> Unit,
currentParagraphGap: Float, // NEW
onParagraphGapChange: (Float) -> Unit, // NEW
currentParagraphGap: Float,
onParagraphGapChange: (Float) -> Unit,
currentImageSize: Float,
onImageSizeChange: (Float) -> Unit,
currentHorizontalMargin: Float,
onHorizontalMarginChange: (Float) -> Unit,
currentFont: ReaderFont,
currentCustomFontName: String?,
onFontOptionClick: () -> Unit,
@ -404,27 +462,28 @@ fun ReaderTextFormatPanel(
onReset: () -> Unit,
isLocalMode: Boolean,
onLocalModeToggle: (Boolean) -> Unit,
onClose: () -> Unit,
modifier: Modifier = Modifier
onClose: () -> Unit
) {
AnimatedVisibility(
visible = isVisible,
enter = slideInVertically { it } + fadeIn(),
exit = slideOutVertically { it } + fadeOut(),
modifier = modifier
) {
Surface(
shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp),
color = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.98f),
tonalElevation = 0.dp,
shadowElevation = 8.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
modifier = Modifier
.fillMaxWidth()
.animateContentSize()
if (isVisible) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet(
onDismissRequest = onClose,
sheetState = sheetState,
scrimColor = Color.Transparent,
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.9f),
contentWindowInsets = { WindowInsets.navigationBars }
) {
val configuration = LocalConfiguration.current
val maxSheetHeight = (configuration.screenHeightDp * 0.7f).dp
Column(
modifier = Modifier.padding(16.dp)
modifier = Modifier
.fillMaxWidth()
.heightIn(max = maxSheetHeight)
.verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 8.dp)
.padding(bottom = 24.dp)
) {
// Header Row (Local/Global + Close/Reset)
Row(
@ -500,7 +559,7 @@ fun ReaderTextFormatPanel(
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
)
// Font Button (Full width)
// Font Button
Surface(
onClick = onFontOptionClick,
shape = RoundedCornerShape(12.dp),
@ -540,7 +599,7 @@ fun ReaderTextFormatPanel(
Spacer(Modifier.height(8.dp))
// Alignment Button (Full width Segmented)
// Alignment Button (Segmented)
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant,
@ -586,49 +645,61 @@ fun ReaderTextFormatPanel(
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(start = 4.dp, bottom = 8.dp)
modifier = Modifier.padding(start = 4.dp, bottom = 12.dp)
)
// Sliders
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Size
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))
Slider(
value = currentFontSize,
onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f,
steps = 24,
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)
}
// Lines
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,
onValueChange = onLineHeightChange,
valueRange = 1.0f..3.0f,
steps = 19,
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)
}
// 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,
onValueChange = onParagraphGapChange,
valueRange = 0.0f..3.0f,
steps = 29,
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)
}
// Resolve the string once outside the lambdas
val originalLabel = stringResource(R.string.label_original)
val noneLabel = stringResource(R.string.label_none)
// Wide, smooth sliders without dots
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
FormatSlider(
label = stringResource(R.string.label_font_size),
value = currentFontSize,
onValueChange = onFontSizeChange,
valueRange = 0.5f..3.0f,
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
)
FormatSlider(
label = stringResource(R.string.label_line_height),
value = currentLineHeight,
onValueChange = onLineHeightChange,
valueRange = 1.0f..3.0f,
formatValue = { if (it <= 1.01f) originalLabel else "%.1fx".format(it) }
)
FormatSlider(
label = stringResource(R.string.label_paragraph_gap),
value = currentParagraphGap,
onValueChange = onParagraphGapChange,
valueRange = 0.0f..3.0f,
formatValue = { if (it in 0.99f..1.01f) originalLabel else "%.1fx".format(it) }
)
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,
pullToTurnEnabled: Boolean,
onPullToTurnChange: (Boolean) -> Unit,
removeEdgePadding: Boolean,
onRemoveEdgePaddingChange: (Boolean) -> Unit,
pullToTurnMultiplier: Float,
onPullToTurnMultiplierChange: (Float) -> 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))
}
}
@ -922,4 +967,138 @@ 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(
ttsState: TtsPlaybackManager.TtsState,
currentRenderMode: RenderMode,
currentChapterIndex: Int,
webViewRef: WebView?,
paginator: IPaginator?,
pagerState: PagerState,
@ -223,14 +224,42 @@ fun TtsHighlightHandler(
val text = ttsState.currentText
val cfi = ttsState.sourceCfi
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) {
val escapedText = escapeJsString(text)
val escapedCfi = escapeJsString(cfi)
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)
} else {
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)
}
}
@ -298,6 +327,7 @@ private fun handleVerticalAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(currentTtsChapterIndex)?.title,
coverImageUri = coverImagePath?.let { android.net.Uri.fromFile(File(it)).toString() },
chapterIndex = currentTtsChapterIndex,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -327,6 +357,7 @@ private fun handleVerticalAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapters.getOrNull(nextIdx)?.title,
coverImageUri = coverImagePath?.let { Uri.fromFile(File(it)).toString() },
chapterIndex = nextIdx,
ttsMode = currentTtsMode,
playbackSource = "READER",
authToken = token
@ -401,6 +432,7 @@ private fun handlePaginatedAutoAdvance(
bookTitle = epubBookTitle,
chapterTitle = chapterTitle,
coverImageUri = coverUriString,
chapterIndex = chapterToTry,
ttsMode = ttsMode,
playbackSource = "READER",
authToken = token
@ -421,4 +453,4 @@ private fun handlePaginatedAutoAdvance(
} else {
onUpdateTtsChapter(null)
}
}
}

View file

@ -96,7 +96,11 @@ object ExternalDictionaryHelper {
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
// 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)
}
}
if (processTextIntent.resolveActivity(pm) != null) {
@ -148,7 +152,9 @@ object ExternalDictionaryHelper {
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (context.getActivity() == null) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
if (translateIntent.resolveActivity(pm) != null) {
context.startActivity(translateIntent)
@ -162,7 +168,9 @@ object ExternalDictionaryHelper {
putExtra(Intent.EXTRA_PROCESS_TEXT, query)
putExtra(Intent.EXTRA_PROCESS_TEXT_READONLY, true)
setPackage(packageName)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
if (context.getActivity() == null) {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
}
if (processTextIntent.resolveActivity(pm) != null) {
@ -281,4 +289,15 @@ object ExternalDictionaryHelper {
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 mathMLRenderer: MathMLRenderer,
private val userTextAlign: TextAlign?,
private val paragraphGapMultiplier: Float
private val paragraphGapMultiplier: Float,
private val imageSizeMultiplier: Float
) : IPaginator {
override var totalPageCount by mutableIntStateOf(0)
private set
@ -267,7 +268,16 @@ class BookPaginator(
}
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()
return hash
}
@ -722,7 +732,8 @@ class BookPaginator(
textMeasurer = textMeasurer,
constraints = constraints,
textStyle = textStyle,
density = density
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
Timber.d("paginateChapter: Calling PaginatorLogic for chapter $chapterIndex.")
val pages = paginate(
@ -971,31 +982,7 @@ class BookPaginator(
return@launch
}
val chapterPages = pageCache[targetChapterIndex] ?: paginateChapter(targetChapterIndex)
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) }
findPageForAnchor(targetChapterIndex, anchor, onNavigationComplete)
}
}
@ -1304,4 +1291,4 @@ class BookPaginator(
return null to null
}
}
}
}

View file

@ -144,7 +144,8 @@ class PaginatedReaderViewModel : ViewModel() {
context = context.applicationContext,
mathMLRenderer = mathMLRenderer,
userTextAlign = null,
paragraphGapMultiplier = paragraphGapMultiplier
paragraphGapMultiplier = paragraphGapMultiplier,
imageSizeMultiplier = 1.0f
)
paginator = newPaginator
@ -174,4 +175,4 @@ class PaginatedReaderViewModel : ViewModel() {
fun onLinkClick(currentChapterPath: String, href: String, onNavigationComplete: (Int) -> Unit) {
paginator?.navigateToHref(currentChapterPath, href, onNavigationComplete)
}
}
}

View file

@ -50,7 +50,8 @@ class SuspendingAndroidBlockMeasurementProvider(
private val textMeasurer: TextMeasurer,
private val constraints: Constraints,
private val textStyle: TextStyle,
private val density: Density
private val density: Density,
private val imageSizeMultiplier: Float
) : BlockMeasurementProvider {
override suspend fun measure(block: ContentBlock): Int {
@ -60,7 +61,8 @@ class SuspendingAndroidBlockMeasurementProvider(
constraints = constraints,
defaultStyle = textStyle,
headerStyle = textStyle.copy(fontWeight = FontWeight.Bold),
density = density
density = density,
imageSizeMultiplier = imageSizeMultiplier
)
}
@ -79,29 +81,12 @@ class SuspendingAndroidBlockMeasurementProvider(
val imageBlock = block.floatedImage
val (imageWidthPx, imageHeightPx) = run {
val imageStyle = imageBlock.style
val intrinsicWidth = imageBlock.intrinsicWidth
val intrinsicHeight = imageBlock.intrinsicHeight
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) {
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)
}
measureScaledImageSizePx(
block = imageBlock,
density = density,
maxWidthPx = constraints.maxWidth.toFloat(),
imageSizeMultiplier = imageSizeMultiplier
)
}
if (imageWidthPx <= 0 || imageHeightPx <= 0) {
@ -668,62 +653,25 @@ private suspend fun measureBlockHeight(
constraints: Constraints,
defaultStyle: TextStyle,
headerStyle: TextStyle,
density: Density
density: Density,
imageSizeMultiplier: Float = 1.0f
): Int {
var verticalPaddingPx = 0f
var horizontalPaddingPx = 0f
var verticalBorderPx = 0f
var horizontalBorderPx = 0f
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 boxMetrics = computeBlockBoxMetrics(block, constraints, density)
val verticalPaddingPx = boxMetrics.verticalPaddingPx
val verticalBorderPx = boxMetrics.verticalBorderPx
val adjustedConstraints = boxMetrics.contentConstraints
val contentHeight = when (block) {
is ParagraphBlock -> {
val paragraphStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign)
val height = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = block.content,
style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign),
style = paragraphStyle,
constraints = adjustedConstraints
).size.height
}
height
height + centeredTextSafetyPaddingPx(paragraphStyle, density)
}
is HeaderBlock -> {
val style = headerStyle.copy(
@ -736,27 +684,15 @@ private suspend fun measureBlockHeight(
constraints = adjustedConstraints
).size.height
}
height
height + centeredTextSafetyPaddingPx(style, density)
}
is ImageBlock -> {
val imageIntrinsicWidth = block.intrinsicWidth
val imageIntrinsicHeight = block.intrinsicHeight
val styledHeightPx = if (block.style.height.isSpecified) with(density) { block.style.height.toPx() } else null
val styledWidthPx = if (block.style.width.isSpecified) with(density) { block.style.width.toPx() } else null
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 measuredHeight = measureScaledImageHeightPx(
block = block,
density = density,
contentMaxWidth = adjustedConstraints.maxWidth.toFloat(),
imageSizeMultiplier = imageSizeMultiplier
) ?: with(density) { 250.dp.toPx() }
val finalHeight = measuredHeight.coerceAtMost(constraints.maxHeight.toFloat()).roundToInt()
Timber.tag("IMAGE_DIAG").d("Measured Image [#${block.blockIndex}]: $finalHeight px (Capped at ${constraints.maxHeight})")
@ -767,14 +703,15 @@ private suspend fun measureBlockHeight(
height
}
is QuoteBlock -> {
val quoteStyle = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign)
val height = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = block.content,
style = defaultStyle.copy(textAlign = block.textAlign ?: defaultStyle.textAlign),
style = quoteStyle,
constraints = adjustedConstraints
).size.height
}
height
height + centeredTextSafetyPaddingPx(quoteStyle, density)
}
is ListItemBlock -> {
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 cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density)
val cellContentHeight = calculateContentHeightWithMargins(cell.content, textMeasurer, cellConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
var cellDecorationHeight = 0f
with(density) {
@ -829,35 +766,18 @@ private suspend fun measureBlockHeight(
val imageBlock = block.floatedImage
val (imageWidthPx, imageHeightPx) = run {
val imageStyle = imageBlock.style
val intrinsicWidth = imageBlock.intrinsicWidth
val intrinsicHeight = imageBlock.intrinsicHeight
if (intrinsicWidth == null || intrinsicHeight == null || intrinsicWidth <= 0f) {
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)
}
measureScaledImageSizePx(
block = imageBlock,
density = density,
maxWidthPx = adjustedConstraints.maxWidth.toFloat(),
imageSizeMultiplier = imageSizeMultiplier
)
}
// If image has no size, it can't float. Just measure the paragraphs.
if (imageWidthPx <= 0 || imageHeightPx <= 0) {
val height = block.paragraphsToWrap.sumOf { p ->
measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density)
measureBlockHeight(p, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
}
return height
}
@ -949,10 +869,10 @@ private suspend fun measureBlockHeight(
val isRow = block.style.flexDirection == "row"
val height = if (isRow) {
block.children.maxOfOrNull { child ->
measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density)
measureBlockHeight(child, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
} ?: 0
} else {
calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density)
calculateContentHeightWithMargins(block.children, textMeasurer, adjustedConstraints, defaultStyle, headerStyle, density, imageSizeMultiplier)
}
height
}
@ -980,7 +900,7 @@ private suspend fun measureBlockHeight(
}
}
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() }
} else {
(contentHeight + verticalPaddingPx + verticalBorderPx).roundToInt()
@ -1000,6 +920,10 @@ private suspend fun splitParagraphBlock(
): Pair<ParagraphBlock, ParagraphBlock>? {
val text = block.content
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) {
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)
}.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")
@ -1021,8 +945,8 @@ private suspend fun splitParagraphBlock(
val layoutResult = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = text,
style = textStyle,
constraints = constraints.copy(maxHeight = Constraints.Infinity)
style = paragraphStyle,
constraints = paragraphConstraints
)
}
@ -1036,7 +960,7 @@ private suspend fun splitParagraphBlock(
var lastVisibleLine = layoutResult.getLineForVerticalPosition(availableTextHeight.toFloat())
if (layoutResult.getLineBottom(lastVisibleLine) > availableHeight.toFloat()) {
if (layoutResult.getLineBottom(lastVisibleLine) > availableTextHeight.toFloat()) {
lastVisibleLine--
}
@ -1056,7 +980,8 @@ private suspend fun splitParagraphBlock(
val part2Layout = withContext(Dispatchers.Main) {
textMeasurer.measure(
text = part2CheckText,
constraints = constraints
style = paragraphStyle,
constraints = paragraphConstraints
)
}
if (part2Layout.lineCount == 1) {
@ -1149,11 +1074,12 @@ private suspend fun calculateContentHeightWithMargins(
constraints: Constraints,
defaultStyle: TextStyle,
headerStyle: TextStyle,
density: Density
density: Density,
imageSizeMultiplier: Float = 1.0f
): Int {
var totalHeight = 0
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) {
if (index > 0) {
val prevMargin = children[index - 1].style.margin.bottom.toPx()
@ -1172,6 +1098,117 @@ private suspend fun calculateContentHeightWithMargins(
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>) {
if (blocks.isNotEmpty()) {
val lastBlock = blocks.last()
@ -1180,4 +1217,4 @@ private fun zeroOutBottomMargin(blocks: MutableList<ContentBlock>) {
setBlockExpectedHeight(copyBlockWithNewStyle(lastBlock, newLastStyle), lastBlock.expectedHeight)
blocks[blocks.size - 1] = newLastBlock
}
}
}

View file

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

View file

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

View file

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

View file

@ -1,9 +1,24 @@
// PdfNavigationDrawerContent.kt
package com.aryan.reader.pdf
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.items
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.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.AlertDialog
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.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legere.pdfiumandroid.api.Bookmark
import io.legere.pdfiumandroid.suspend.PdfDocumentKt
import kotlinx.coroutines.delay
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)
@Composable
internal fun PdfNavigationDrawerContent(
pdfDocument: ReaderDocument?,
flatTableOfContents: List<TocEntry>,
bookmarks: Set<PdfBookmark>,
userHighlights: List<PdfUserHighlight>,
currentPage: Int,
totalPages: Int,
customHighlightColors: Map<PdfHighlightColor, Color>,
onPageSelected: (Int) -> Unit,
onRenameBookmark: (PdfBookmark, String) -> Unit,
@ -44,11 +290,15 @@ internal fun PdfNavigationDrawerContent(
onNoteRequested: (String?) -> Unit,
onCloseDrawer: () -> Unit
) {
val drawerPagerState = rememberPagerState(pageCount = { 3 })
val drawerPagerState = rememberPagerState(pageCount = { 4 })
val drawerScope = rememberCoroutineScope()
Column(modifier = Modifier.fillMaxSize()) {
TabRow(selectedTabIndex = drawerPagerState.currentPage) {
ScrollableTabRow(
selectedTabIndex = drawerPagerState.currentPage,
edgePadding = 8.dp,
modifier = Modifier.fillMaxWidth()
) {
Tab(selected = drawerPagerState.currentPage == 0, onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(0) }
}, text = { Text("Chapters") })
@ -68,6 +318,14 @@ internal fun PdfNavigationDrawerContent(
text = { Text("Highlights") },
modifier = Modifier.testTag("HighlightsTab")
)
Tab(
selected = drawerPagerState.currentPage == 3,
onClick = {
drawerScope.launch { drawerPagerState.animateScrollToPage(3) }
},
text = { Text("Pages") },
modifier = Modifier.testTag("PagesTab")
)
}
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
internal fun ZoomPercentageIndicator(percentage: Int) {
internal fun ZoomPercentageIndicator(
percentage: Int,
onResetZoomClick: () -> Unit
) {
Surface(
shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.scrim.copy(alpha = 0.8f)
) {
Text(
text = "$percentage%",
color = Color.White,
style = MaterialTheme.typography.bodyLarge,
androidx.compose.foundation.layout.Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
) {
Text(
text = "$percentage%",
color = Color.White,
style = MaterialTheme.typography.bodyLarge
)
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.util.LruCache
import androidx.activity.compose.BackHandler
import androidx.compose.ui.graphics.drawscope.withTransform
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.RepeatMode
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.PointerType
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.positionChanged
import androidx.compose.ui.input.pointer.util.VelocityTracker
@ -115,6 +119,7 @@ import androidx.core.graphics.scale
import androidx.core.graphics.set
import com.aryan.reader.R
import com.aryan.reader.SearchResult
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
@ -187,6 +192,78 @@ data class PageLink(
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 {
fun calculateFountainPenPoints(
points: List<PdfPoint>, baseWidth: Float, pageWidth: Float, pageHeight: Float
@ -394,7 +471,8 @@ internal fun PdfPageComposable(
searchHighlightMode: SearchHighlightMode = SearchHighlightMode.ALL,
searchResultToHighlight: SearchResult?,
ocrHoverHighlights: StableHolder<List<RectF>> = StableHolder(emptyList()),
onSingleTap: () -> Unit,
onPreSingleTap: ((Offset) -> Boolean)? = null,
onSingleTap: (Offset?) -> Unit,
isProUser: Boolean,
onShowDictionaryUpsellDialog: () -> Unit,
onWordSelectedForAiDefinition: (String) -> Unit,
@ -415,6 +493,7 @@ internal fun PdfPageComposable(
isVerticalScroll: Boolean = false,
visualScaleProvider: () -> Float = { 1f },
clearSelectionTrigger: Long = 0L,
resetZoomTrigger: Long = 0L,
onTtsHighlightCenterCalculated: ((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),
@ -423,8 +502,8 @@ internal fun PdfPageComposable(
isEditMode: Boolean = false,
drawingState: PdfDrawingState? = null,
pageAnnotations: () -> List<PdfAnnotation> = { emptyList() },
onDrawStart: (PdfPoint) -> Unit = {},
onDraw: (PdfPoint) -> Unit = {},
onDrawStart: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDraw: (PdfPoint, Boolean) -> Unit = { _, _ -> },
onDrawEnd: () -> Unit = {},
visibleScreenRect: () -> IntRect? = { null },
selectedTool: InkType = InkType.PEN,
@ -441,6 +520,7 @@ internal fun PdfPageComposable(
isScrollLocked: Boolean = false,
isVisible: Boolean = true,
isActivePage: Boolean = true,
isBubbleZoomModeActive: Boolean = false,
isStylusOnlyMode: Boolean = false,
isAutoScrollPlaying: Boolean = false,
isHighlighterSnapEnabled: Boolean = false,
@ -455,7 +535,7 @@ internal fun PdfPageComposable(
onPaletteClick: (() -> Unit)? = null,
lockedState: Triple<Float, Float, Float>? = null,
onZoomAndPanChanged: ((Float, Offset) -> Unit)? = null,
onDetectPanels: suspend (Bitmap) -> List<android.graphics.RectF> = { emptyList() },
onDetectBubbles: suspend (Int, Bitmap) -> List<SpeechBubble> = { _, _ -> emptyList() },
onShowPanelPopup: (Bitmap) -> Unit = {}
) {
val pdfDocumentItem = pdfDocument.item
@ -475,10 +555,7 @@ internal fun PdfPageComposable(
LocalContext.current
val viewConfiguration = LocalViewConfiguration.current
val coroutineScope = rememberCoroutineScope()
Timber.d(
"PdfPageComposable recompose: page=$pageIndex, isScrolling=$isScrolling, visualScale=$visualScaleProvider"
)
var isStylusEraserOverride by remember { mutableStateOf(false) }
var layoutCoordinates by remember { mutableStateOf<LayoutCoordinates?>(null) }
@ -493,6 +570,7 @@ internal fun PdfPageComposable(
}
val currentOnSingleTap by rememberUpdatedState(onSingleTap)
val currentOnPreSingleTap by rememberUpdatedState(onPreSingleTap)
val currentOnDoubleTap by rememberUpdatedState(onDoubleTap)
val effectiveScale = if (isZoomEnabled && !isVerticalScroll) scale else externalScale
@ -618,9 +696,7 @@ internal fun PdfPageComposable(
}
LaunchedEffect(centeringOffsetX, centeringOffsetY, pageIndex) {
Timber.d(
"PdfPageComposable Page $pageIndex | Centering Offset: x=$centeringOffsetX, y=$centeringOffsetY"
)
}
var showMagnifier by remember { mutableStateOf(false) }
@ -646,6 +722,132 @@ internal fun PdfPageComposable(
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) {
onDispose {
val currentBitmap = bitmapState
@ -653,6 +855,7 @@ internal fun PdfPageComposable(
if (currentBitmap != null && !currentBitmap.isRecycled && currentBitmap !== cachedBitmap) {
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 errorOcrSelection = stringResource(R.string.error_ocr_selection)
val errorProcessingPage = stringResource(R.string.error_processing_page)
@ -2487,7 +2716,8 @@ internal fun PdfPageComposable(
isEditMode,
selectedTool,
isStylusOnlyMode,
userHighlightScreenRects
userHighlightScreenRects,
bubbleTapSlopPx
) {
val isTapDetectionAllowed = !isEditMode ||
selectedTool == InkType.TEXT ||
@ -2496,9 +2726,48 @@ internal fun PdfPageComposable(
if (!isTapDetectionAllowed) return@pointerInput
detectTapGestures(onTap = { tapOffset ->
if (currentOnPreSingleTap?.invoke(tapOffset) == true) {
return@detectTapGestures
}
val tapInContentCoords = screenToContentCoordinates(tapOffset)
val tapXInBitmap = tapInContentCoords.x
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 {
val nativeResult = withContext(Dispatchers.IO) {
@ -2660,7 +2929,7 @@ internal fun PdfPageComposable(
currentPageRotation,
)
} else {
currentOnSingleTap()
currentOnSingleTap(tapOffset)
}
}
}, onDoubleTap = { tapOffset ->
@ -2670,37 +2939,6 @@ internal fun PdfPageComposable(
val startScale = scale
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 targetOffsetUnbounded = if (targetScale <= 1.1f) {
Offset.Zero
@ -3017,12 +3255,20 @@ internal fun PdfPageComposable(
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 startPos = down.position
var dragStarted = false
val touchSlop = viewConfiguration.touchSlop
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
eraserPosition = down.position
}
@ -3034,6 +3280,7 @@ internal fun PdfPageComposable(
drawingState?.onDrawCancel()
}
eraserPosition = null
isStylusEraserOverride = false
return@awaitEachGesture
}
@ -3051,12 +3298,13 @@ internal fun PdfPageComposable(
val normY =
(contentPos.y / actualBitmapHeightPx).coerceIn(0f, 1f)
onDrawStart(PdfPoint(normX, normY))
onDrawStart(PdfPoint(normX, normY), isEraserOverride)
onDrawEnd()
} else {
onDrawEnd()
}
eraserPosition = null
isStylusEraserOverride = false
return@awaitEachGesture
}
@ -3077,7 +3325,7 @@ internal fun PdfPageComposable(
0f, 1f
)
onDrawStart(
PdfPoint(startNormX, startNormY)
PdfPoint(startNormX, startNormY), isEraserOverride
)
val currContentPos = screenToContentCoordinates(
@ -3091,9 +3339,9 @@ internal fun PdfPageComposable(
(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
}
change.consume()
@ -3106,9 +3354,9 @@ internal fun PdfPageComposable(
(currContentPos.x / actualBitmapWidthPx).coerceIn(0f, 1f)
val currNormY =
(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
}
change.consume()
@ -3118,6 +3366,7 @@ internal fun PdfPageComposable(
}
} finally {
eraserPosition = null
isStylusEraserOverride = false
}
}, contentAlignment = Alignment.Center
) {
@ -3230,10 +3479,6 @@ internal fun PdfPageComposable(
offset = Offset.Zero
onScaleChanged(1f)
}
Timber.d(
"PdfPageComposable Page $pageIndex initialized/resized/locked. scale=$scale, offset=$offset"
)
}
LaunchedEffect(
@ -3386,15 +3631,8 @@ internal fun PdfPageComposable(
val viewContainerHeightPx =
with(density) { currentContainerMaxHeight.toPx().toInt() }
Timber.d(
"PdfPageComposable Page $pageIndex | viewContainerPx: ${viewContainerWidthPx}x${viewContainerHeightPx}"
)
if (viewContainerWidthPx <= 0 || viewContainerHeightPx <= 0) {
if (bitmapState == null) isLoadingPage = true
Timber.d(
"PdfPageComposable: viewContainer dimensions invalid ($viewContainerWidthPx x $viewContainerHeightPx), waiting."
)
return@LaunchedEffect
}
@ -3948,6 +4186,7 @@ internal fun PdfPageComposable(
isEditMode = isEditMode,
selectedTool = selectedTool,
eraserPosition = eraserPosition,
isStylusEraserOverride = isStylusEraserOverride,
activeToolThickness = activeToolThickness,
richTextController = richTextController,
textBoxes = textBoxes,
@ -3960,7 +4199,14 @@ internal fun PdfPageComposable(
onDragPageTurn = onDragPageTurn,
draggingBoxId = draggingBoxId,
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()) {
imageRects.forEach { imgRect ->
val scaledImgRectLeft = (imgRect.left * effectiveScale).roundToInt()
val scaledImgRectTop = (imgRect.top * effectiveScale).roundToInt()
val scaledImgRectRight = (imgRect.right * effectiveScale).roundToInt()
val scaledImgRectBottom = (imgRect.bottom * effectiveScale).roundToInt()
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 intersectLeft = max(imgRect.left, tile.renderRect.left)
val intersectTop = max(imgRect.top, tile.renderRect.top)
val intersectRight = min(imgRect.right, tile.renderRect.right)
val intersectBottom = min(imgRect.bottom, tile.renderRect.bottom)
val iw = intersectRight - intersectLeft
val ih = intersectBottom - intersectTop
@ -4151,7 +4392,6 @@ private fun PdfHighlightsLayer(
selectionHighlightColor: Color,
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap()
) {
Timber.d("PdfHighlightsLayer Recompose")
Canvas(modifier = Modifier
.fillMaxSize()
.graphicsLayer()) {
@ -4751,6 +4991,7 @@ private fun PdfPageRenderer(
isEditMode: Boolean,
selectedTool: InkType,
eraserPosition: Offset?,
isStylusEraserOverride: Boolean,
richTextController: RichTextController?,
textBoxes: List<PdfTextBox>,
selectedTextBoxId: String?,
@ -4769,6 +5010,13 @@ private fun PdfPageRenderer(
onTts: (Int, Int) -> Unit,
activeToolThickness: Float,
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(
@ -4976,7 +5224,7 @@ private fun PdfPageRenderer(
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()) {
val radiusPx = if (activeToolThickness > 0f && staticData.targetWidth > 0) {
activeToolThickness * staticData.targetWidth * scale // Calculate dynamic size based on tool settings scale
@ -5203,6 +5451,150 @@ private fun PdfPageRenderer(
if (isPerformingOcr && ocrRipplePos != null) {
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)
)
}
}
}
}
}
}
}
}
@ -5409,4 +5801,4 @@ private fun getNativePointer(obj: Any): Long {
} catch (_: Exception) {}
return 0L
}
}

View file

@ -47,6 +47,7 @@ enum class PdfReaderTool(val title: String, val category: String) {
THEME("Theme Settings", "Top Bar"),
LOCK_PANNING("Lock Panning", "Top Bar"),
VISUAL_OPTIONS("Visual Options", "Overflow Menu"),
TAP_TO_TURN("Tap to Turn Pages", "Overflow Menu"),
FULL_SCREEN("Full Screen", "Top Bar"),
SLIDER("Navigation Slider", "Bottom Bar"),
TOC("Sidebar", "Bottom Bar"),
@ -370,4 +371,4 @@ internal fun savePdfDarkMode(context: Context, isDark: Boolean) {
internal fun loadPdfDarkMode(context: Context): Boolean {
val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
return prefs.getBoolean(PDF_DARK_MODE_KEY, false)
}
}

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.material.icons.Icons
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.material3.*
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.platform.testTag
import androidx.compose.ui.unit.sp
import com.aryan.reader.BuildConfig
import com.aryan.reader.FileType
import com.aryan.reader.R
@ -81,6 +83,8 @@ internal fun PdfTopBar(
onShowCustomizeTools: () -> Unit,
onShowOcrLanguage: () -> Unit,
onShowVisualOptions: () -> Unit,
tapToNavigateEnabled: Boolean,
onToggleTapToNavigate: () -> Unit,
onChangeDisplayMode: (DisplayMode) -> Unit,
onToggleKeepScreenOn: () -> Unit,
onStartAutoScroll: () -> Unit,
@ -94,7 +98,8 @@ internal fun PdfTopBar(
onPrint: () -> Unit,
onTabClick: (String) -> Unit,
onTabClose: (String) -> Unit,
onNewTabClick: () -> Unit
onNewTabClick: () -> Unit,
onGenerateDemoAnnotations: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars,
@ -179,6 +184,9 @@ internal fun PdfTopBar(
}
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) {
Icon(Icons.Default.Star, contentDescription = "Open Pen Playground", tint = MaterialTheme.colorScheme.primary)
}
@ -238,6 +246,26 @@ internal fun PdfTopBar(
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)) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_keep_screen_on)) },
@ -433,13 +461,17 @@ fun PdfBottomBar(
isEditMode: Boolean,
isTtsSessionActive: Boolean,
ttsErrorMessage: String?,
jumpBackPage: Int?,
onJumpBack: () -> Unit,
onShowSlider: () -> Unit,
onShowToc: () -> Unit,
onSearchClick: () -> Unit,
onToggleHighlights: () -> Unit,
onShowAiHub: () -> Unit,
onToggleEditMode: () -> Unit,
onToggleTts: () -> Unit
onToggleTts: () -> Unit,
isBubbleZoomModeActive: Boolean,
onToggleBubbleZoom: () -> Unit
) {
AnimatedVisibility(
visible = showStandardBars && !searchStateActive,
@ -456,8 +488,35 @@ fun PdfBottomBar(
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = bottomBarPadding).height(56.dp).padding(horizontal = 8.dp).horizontalScroll(bottomBarScrollState),
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)) {
TooltipIconButton(
text = stringResource(R.string.tooltip_slider),
@ -532,10 +591,24 @@ 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 {
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
import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.graphics.RectF
import androidx.compose.animation.AnimatedVisibility
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.graphicsLayer
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.positionChanged
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.zIndex
import com.aryan.reader.SearchResult
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
@ -217,8 +222,8 @@ internal fun PdfVerticalReader(
isEditMode: Boolean = false,
allAnnotations: () -> Map<Int, List<PdfAnnotation>> = { emptyMap() },
drawingState: PdfDrawingState,
onDrawStart: (Int, PdfPoint) -> Unit,
onDraw: (Int, PdfPoint) -> Unit,
onDrawStart: (Int, PdfPoint, Boolean) -> Unit,
onDraw: (Int, PdfPoint, Boolean) -> Unit,
onDrawEnd: () -> Unit,
onOcrModelDownloading: () -> Unit = {},
selectedTool: InkType,
@ -247,7 +252,10 @@ internal fun PdfVerticalReader(
customHighlightColors: Map<PdfHighlightColor, Color> = emptyMap(),
onPaletteClick: () -> Unit = {},
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.") }
DisposableEffect(state) {
@ -260,6 +268,7 @@ internal fun PdfVerticalReader(
}
}
var globalEraserPosition by remember { mutableStateOf<Offset?>(null) }
var isStylusEraserOverride by remember { mutableStateOf(false) }
val isDarkMode = activeTheme.isDark || activeTheme.id == "reverse"
BoxWithConstraints(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.TopStart) {
val imeInsets = WindowInsets.ime
@ -393,18 +402,17 @@ internal fun PdfVerticalReader(
val zoomedDocHeight = totalDocHeight * savedScale
val minPanY = (screenHeight - footerHeightPx - zoomedDocHeight).coerceAtMost(headerHeightPx)
val maxPanY = headerHeightPx
zoomAnimatable.stop()
panXAnimatable.stop()
panYAnimatable.stop()
panXAnimatable.updateBounds(minPanX, maxPanX)
panYAnimatable.updateBounds(minPanY, maxPanY)
panYAnimatable.updateBounds(minPanY, headerHeightPx)
zoomAnimatable.snapTo(savedScale)
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}")
@ -492,6 +500,65 @@ internal fun PdfVerticalReader(
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(
totalDocHeight, screenHeight, headerHeightPx, footerHeightPx, zoomAnimatable.value, isInteracting, isFlinging, isResizing
) {
@ -920,6 +987,15 @@ internal fun PdfVerticalReader(
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>? {
val zoom = zoomAnimatable.value
val panX = panXAnimatable.value
@ -943,14 +1019,14 @@ internal fun PdfVerticalReader(
var isCanceled = false
try {
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
globalEraserPosition = down.position
}
val startData = getPageAndPoint(down.position)
if (startData != null) {
val (pageIndex, point) = startData
onDrawStart(pageIndex, point)
onDrawStart(pageIndex, point, isEraserOverride)
down.consume()
}
@ -969,7 +1045,7 @@ internal fun PdfVerticalReader(
if (change == null || !change.pressed) break
if (change.positionChanged()) {
if (selectedTool == InkType.ERASER) {
if (selectedTool == InkType.ERASER || isEraserOverride) {
globalEraserPosition = change.position
}
@ -977,11 +1053,11 @@ internal fun PdfVerticalReader(
if (dragData != null) {
val (pageIndex, point) = dragData
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER) {
if (pageIndex != lastPageIndex && selectedTool != InkType.ERASER && !isEraserOverride) {
onDrawEnd()
onDrawStart(pageIndex, point)
onDrawStart(pageIndex, point, isEraserOverride)
} else {
onDraw(pageIndex, point)
onDraw(pageIndex, point, isEraserOverride)
}
lastPageIndex = pageIndex
}
@ -993,6 +1069,7 @@ internal fun PdfVerticalReader(
onDrawEnd()
}
globalEraserPosition = null
isStylusEraserOverride = false
}
}
}
@ -1472,16 +1549,20 @@ internal fun PdfVerticalReader(
}
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 onDrawLambda = remember(page.index) {
{ point: PdfPoint -> currentOnDraw(page.index, point) }
{ point: PdfPoint, isEraserOverride: Boolean ->
currentOnDraw(page.index, point, isEraserOverride)
}
}
val onSingleTapLambda = remember(onPageClick) {
{
{ _: Offset? ->
selectionClearTrigger++
onPageClick()
}
@ -1759,7 +1840,9 @@ internal fun PdfVerticalReader(
draggingBoxId = null
}
},
draggingBoxId = draggingBoxId
draggingBoxId = draggingBoxId,
isBubbleZoomModeActive = isBubbleZoomModeActive,
onDetectBubbles = onDetectBubbles
)
}
@ -1875,6 +1958,7 @@ internal fun PdfVerticalReader(
animationSpec = tween(durationMillis = 300),
label = "scrollbarAlpha"
)
val safeCurrentPage = if (totalPages > 0) state.currentPage.coerceIn(0, totalPages - 1) else 0
val samsungBlue = Color(0xFF4285F4)
val samsungBlueDark = Color(0xFF1976D2)
@ -1934,7 +2018,7 @@ internal fun PdfVerticalReader(
.alpha(scrollbarAlpha)) {
Row(verticalAlignment = Alignment.CenterVertically) {
AnimatedVisibility(
visible = isDraggingScrollbar,
visible = isDraggingScrollbar && totalPages > 0,
enter = fadeIn() + androidx.compose.animation.slideInHorizontally {
it / 2
},
@ -1948,7 +2032,7 @@ internal fun PdfVerticalReader(
modifier = Modifier.padding(end = 12.dp)
) {
Text(
text = "${state.currentPage + 1}/${totalPages}",
text = "${safeCurrentPage + 1}/$totalPages",
style = MaterialTheme.typography.titleMedium.copy(
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()) {
val pos = globalEraserPosition!!
val radiusPx = if (activeToolThickness > 0f) {
@ -2127,4 +2211,4 @@ internal fun PdfVerticalReader(
}
}
}
}
}

View file

@ -92,6 +92,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
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.ArrowUpward
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.SummaryCacheManager
import com.aryan.reader.TtsSettingsSheet
import com.aryan.reader.ml.SpeechBubble
import com.aryan.reader.epubreader.AutoScrollControls
import com.aryan.reader.epubreader.DictionarySettingsDialog
import com.aryan.reader.epubreader.ExternalDictionaryHelper
import com.aryan.reader.epubreader.SystemUiMode
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.loadCustomThemes
import com.aryan.reader.paginatedreader.TtsChunk
@ -252,6 +256,7 @@ import org.json.JSONObject
import timber.log.Timber
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.LinkedHashSet
import java.net.HttpURLConnection
import java.net.URL
import kotlin.math.PI
@ -290,6 +295,7 @@ fun PdfViewerScreen(
val focusManager = LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
var displayMode by remember { mutableStateOf(loadDisplayMode(context)) }
var tapToNavigateEnabled by remember { mutableStateOf(loadTapToNavigateSetting(context)) }
var showThemePanel by remember { mutableStateOf(false) }
var currentThemeId by remember { mutableStateOf(loadPdfThemeId(context)) }
var excludeImages by remember { mutableStateOf(com.aryan.reader.loadExcludeImages(context)) }
@ -335,9 +341,11 @@ fun PdfViewerScreen(
savePdfHiddenTools(context, newSet)
}
val isOss = BuildConfig.FLAVOR == "oss"
val executeWithOcrCheck = remember(hasSelectedOcrLanguage) {
{ action: () -> Unit ->
if (hasSelectedOcrLanguage) {
if (isOss || hasSelectedOcrLanguage) {
action()
} else {
pendingActionAfterOcrSelection = action
@ -584,6 +592,9 @@ fun PdfViewerScreen(
var customHighlightColors by remember { mutableStateOf(loadCustomHighlightColors(context)) }
var showHighlightColorPicker by remember { mutableStateOf(false) }
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 dockOffset by remember { mutableStateOf(initialDockOffset) }
@ -655,22 +666,11 @@ fun PdfViewerScreen(
snapPreviewLocation,
isEditMode,
isDockDragging,
showStandardBars,
systemUiMode,
statusBarHeightDp
) {
if (!isEditMode) {
var h = 0.dp
if (showStandardBars) {
h += 56.dp
}
val isStatusBarVisible = systemUiMode == SystemUiMode.DEFAULT || (systemUiMode == SystemUiMode.SYNC && showStandardBars)
if (isStatusBarVisible) {
h += statusBarHeightDp
}
h
0.dp
} else {
val isStickyTop = dockLocation == DockLocation.TOP && !isDockDragging
val isPreviewingTop = snapPreviewLocation == DockLocation.TOP
@ -686,6 +686,31 @@ fun PdfViewerScreen(
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(
dockLocation,
snapPreviewLocation,
@ -820,9 +845,155 @@ fun PdfViewerScreen(
}
}
}
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) {
if (isDocumentReady && totalPages > 0) {
if (initialScrollDone) {
@ -989,6 +1160,7 @@ fun PdfViewerScreen(
var isLoadingDocument by remember { mutableStateOf(true) }
var selectionClearTrigger by remember { mutableLongStateOf(0L) }
var resetZoomTrigger by remember { mutableLongStateOf(0L) }
val displayPageRatios by remember(pageAspectRatios, virtualPages) {
derivedStateOf {
@ -1879,7 +2051,6 @@ fun PdfViewerScreen(
val onDictionaryLookupStable = remember(executeWithOcrCheck, useOnlineDictionary, selectedDictPackage, uiState.credits, isProUser) {
{ text: String ->
executeWithOcrCheck {
val isOss = BuildConfig.FLAVOR == "oss"
val effectiveUseOnline = !isOss && useOnlineDictionary
if (effectiveUseOnline) {
@ -1954,6 +2125,14 @@ fun PdfViewerScreen(
{ targetPage: Int ->
coroutineScope.launch {
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) {
pagerState.animateScrollToPage(targetPage)
} 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) {
if (pagerState.isScrollInProgress) {
if (showBars) {
showBars = false
Timber.d("Pager scroll detected, hiding bars.")
}
if (displayMode == DisplayMode.PAGINATION && !isAutoPagingForTts && (ttsState.isPlaying || ttsState.isLoading)) {
ttsController.stop()
}
@ -2986,6 +3154,14 @@ fun PdfViewerScreen(
val onInternalLinkNav: (Int) -> Unit = { targetPage ->
coroutineScope.launch {
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) {
pagerState.animateScrollToPage(targetPage)
} else {
@ -3014,16 +3190,24 @@ fun PdfViewerScreen(
fun navigateToPdfSearchResult(result: SearchResult) {
currentPdfSearchResult = result
searchHighlightTarget = result
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 (pagerState.currentPage != result.locationInSource) {
pagerState.scrollToPage(result.locationInSource)
if (pagerState.currentPage != targetPage) {
pagerState.scrollToPage(targetPage)
}
} else {
verticalReaderState.scrollToPage(result.locationInSource)
verticalReaderState.scrollToPage(targetPage)
}
}
}
@ -3092,13 +3276,23 @@ fun PdfViewerScreen(
drawerState = drawerState, gesturesEnabled = drawerState.isOpen, drawerContent = {
ModalDrawerSheet(modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars)) {
PdfNavigationDrawerContent(
pdfDocument = pdfDocument,
flatTableOfContents = flatTableOfContents,
bookmarks = bookmarks,
userHighlights = userHighlights,
currentPage = currentPage,
totalPages = totalDisplayPages,
customHighlightColors = customHighlightColors,
onPageSelected = { targetPage ->
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) {
pagerState.scrollToPage(targetPage)
} else {
@ -3203,6 +3397,44 @@ fun PdfViewerScreen(
val stablePdfDocument = remember(pdfDocument) { StableHolder(pdfDocument!!) }
when (displayMode) {
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()) {
HorizontalPager(
state = pagerState,
@ -3291,9 +3523,10 @@ fun PdfViewerScreen(
@Suppress("ControlFlowWithEmptyBody") val onDrawPagination =
remember(pageIndex) {
{ point: PdfPoint ->
if (currentSelectedTool == InkType.TEXT) {
} else if (currentSelectedTool == InkType.ERASER) {
{ point: PdfPoint, isEraserOverride: Boolean ->
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
@ -3328,12 +3561,13 @@ fun PdfViewerScreen(
@Suppress("ControlFlowWithEmptyBody") val onDrawStartPagination =
remember(pageIndex) {
{ point: PdfPoint ->
{ point: PdfPoint, isEraserOverride: Boolean ->
if (showToolSettings) {
showToolSettings = false
} else {
if (currentSelectedTool == InkType.TEXT) {
} else if (currentSelectedTool == InkType.ERASER) {
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
@ -3362,7 +3596,7 @@ fun PdfViewerScreen(
drawingState.onDrawStart(
pageIndex,
pointWithTime,
currentSelectedTool,
effectiveTool,
currentStrokeColorState,
currentStrokeWidthState
)
@ -3403,7 +3637,8 @@ fun PdfViewerScreen(
modifier = Modifier.fillMaxSize(),
showAllTextHighlights = showAllTextHighlights,
onHighlightLoading = { /* no-op for paginated mode */ },
onSingleTap = onSingleTapStable,
onPreSingleTap = onPaginationPreSingleTap,
onSingleTap = { _ -> onSingleTapStable() },
isProUser = isProUser,
onShowDictionaryUpsellDialog = {
if (useOnlineDictionary) {
@ -3420,6 +3655,7 @@ fun PdfViewerScreen(
onBookmarkClick = { onToggleBookmark(pageIndex) },
isZoomEnabled = true,
clearSelectionTrigger = selectionClearTrigger,
resetZoomTrigger = resetZoomTrigger,
pageAnnotations = pageAnnotationsProvider,
drawingState = drawingState,
onDrawStart = onDrawStartPagination,
@ -3470,12 +3706,11 @@ fun PdfViewerScreen(
currentActiveOffset = newOffset
}
},
onDetectPanels = { bitmap ->
Toast.makeText(context, "Scanning for panels...", Toast.LENGTH_SHORT).show()
viewModel.detectComicPanels(bitmap, context)
onDetectBubbles = { sourcePageIndex, bitmap ->
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
},
onShowPanelPopup = { croppedBitmap ->
poppedUpPanelBitmap = croppedBitmap
onShowPanelPopup = { bitmapWithRects ->
poppedUpPanelBitmap = bitmapWithRects
},
onTwoFingerSwipe = { direction ->
coroutineScope.launch {
@ -3647,7 +3882,15 @@ fun PdfViewerScreen(
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,
isActivePage = pagerState.currentPage == pageIndex,
isScrolling = pagerState.isScrollInProgress
@ -3718,12 +3961,13 @@ fun PdfViewerScreen(
@Suppress("ControlFlowWithEmptyBody") val onDrawStartStable =
remember {
{ pageIndex: Int, point: PdfPoint ->
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
if (showToolSettings) {
showToolSettings = false
} else {
if (currentSelectedTool == InkType.TEXT) {
} else if (currentSelectedTool == InkType.ERASER) {
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.TEXT) {
} else if (effectiveTool == InkType.ERASER) {
lastEraserPoint = point
erasedAnnotationsFromStroke.clear()
@ -3753,7 +3997,7 @@ fun PdfViewerScreen(
drawingState.onDrawStart(
pageIndex,
pointWithTime,
currentSelectedTool,
effectiveTool,
currentStrokeColorState,
currentStrokeWidthState
)
@ -3763,8 +4007,9 @@ fun PdfViewerScreen(
}
val onDrawStable = remember(isHighlighterSnapEnabled, isCurrentToolHighlighter, calculateSnappedPoint) {
{ pageIndex: Int, point: PdfPoint ->
if (currentSelectedTool == InkType.ERASER) {
{ pageIndex: Int, point: PdfPoint, isEraserOverride: Boolean ->
val effectiveTool = if (isEraserOverride) InkType.ERASER else currentSelectedTool
if (effectiveTool == InkType.ERASER) {
val aspectRatio = pageAspectRatios.getOrElse(pageIndex) { 1f }
val existing = allAnnotations[pageIndex] ?: emptyList()
val toRemove = existing.filter {
@ -3915,6 +4160,11 @@ fun PdfViewerScreen(
onZoomAndPanChanged = { newScale, newOffset ->
currentActiveScale = newScale
currentActiveOffset = newOffset
},
resetZoomTrigger = resetZoomTrigger,
isBubbleZoomModeActive = isBubbleZoomModeActive,
onDetectBubbles = { sourcePageIndex, bitmap ->
detectSpeechBubblesForPage(sourcePageIndex, bitmap)
}
)
}
@ -4108,7 +4358,7 @@ fun PdfViewerScreen(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.padding(top = if (showBars) verticalHeaderHeight else 0.dp)
.padding(top = topOverlayInset)
.padding(8.dp)
) {
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 ---
AnimatedVisibility(
visible = isPageSliderVisible,
@ -4209,6 +4508,15 @@ fun PdfViewerScreen(
scrubDebounceJob.value = coroutineScope.launch {
delay(200)
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) {
pagerState.scrollToPage(
newValue.roundToInt()
@ -4418,10 +4726,17 @@ fun PdfViewerScreen(
},
onShowCustomizeTools = { showCustomizeToolsSheet = true },
onShowOcrLanguage = {
hasSelectedOcrLanguage = true
showOcrLanguageDialog = true
if (!isOss) {
hasSelectedOcrLanguage = true
showOcrLanguageDialog = true
}
},
onShowVisualOptions = { showVisualOptionsSheet = true },
tapToNavigateEnabled = tapToNavigateEnabled,
onToggleTapToNavigate = {
tapToNavigateEnabled = !tapToNavigateEnabled
saveTapToNavigateSetting(context, tapToNavigateEnabled)
},
onChangeDisplayMode = { displayMode = it },
onToggleKeepScreenOn = {
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(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = verticalHeaderHeight)
.padding(top = topOverlayInset)
.fillMaxWidth()
.padding(horizontal = 8.dp),
showStandardBars = showStandardBars,
@ -4502,7 +4832,7 @@ fun PdfViewerScreen(
Column(
modifier = Modifier
.fillMaxSize()
.padding(top = verticalHeaderHeight)
.padding(top = topOverlayInset)
.background(MaterialTheme.colorScheme.surface)
) {
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
PdfBottomBar(
modifier = Modifier.align(Alignment.BottomCenter),
@ -4687,6 +5067,20 @@ fun PdfViewerScreen(
isEditMode = isEditMode,
isTtsSessionActive = isTtsSessionActive,
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 = {
val currentPageForSlider = if (displayMode == DisplayMode.PAGINATION) pagerState.currentPage else verticalReaderState.currentPage
sliderStartPage = currentPageForSlider
@ -4736,6 +5130,16 @@ fun PdfViewerScreen(
} else {
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()
) {
val percentage = (currentPageScale * 100).roundToInt()
ZoomPercentageIndicator(percentage = percentage)
ZoomPercentageIndicator(
percentage = percentage,
onResetZoomClick = {
resetZoomTrigger = System.currentTimeMillis()
}
)
}
val isImeVisible = WindowInsets.ime.getBottom(LocalDensity.current) > 0
@ -5465,7 +5874,7 @@ fun PdfViewerScreen(
) {
Image(
bitmap = poppedUpPanelBitmap!!.asImageBitmap(),
contentDescription = "Zoomed Panel",
contentDescription = "Annotated Page",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
@ -5485,7 +5894,7 @@ fun PdfViewerScreen(
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Close Panel",
contentDescription = "Close Image",
tint = Color.White
)
}
@ -5500,6 +5909,30 @@ fun PdfViewerScreen(
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) {
ModalBottomSheet(
onDismissRequest = { showNewTabSheet = false },
@ -5656,7 +6089,7 @@ fun PdfViewerScreen(
}
}
if (showOcrLanguageDialog) {
if (showOcrLanguageDialog && !isOss) {
OcrLanguageSelectionDialog(
currentLanguage = ocrLanguage,
isFirstRun = !hasSelectedOcrLanguage,
@ -6037,6 +6470,17 @@ fun PdfViewerScreen(
currentTtsMode = currentTtsMode,
isCollapsed = isTtsCollapsed,
onCollapseChange = { isTtsCollapsed = it },
onLocateCurrentChunk = {
ttsPageData?.pageIndex?.let { targetPage ->
coroutineScope.launch {
if (displayMode == DisplayMode.PAGINATION) {
pagerState.scrollToPage(targetPage)
} else {
verticalReaderState.scrollToPage(targetPage)
}
}
}
},
onOpenTtsSettings = { showTtsSettingsSheet = true },
onClose = {
ttsController.stop()
@ -6165,4 +6609,4 @@ fun PdfViewerScreen(
}
}
}
}
}

View file

@ -147,7 +147,35 @@ class BaseTtsSynthesizer(private val context: Context) {
if (tts == null) return
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
@ -269,4 +297,4 @@ class BaseTtsSynthesizer(private val context: Context) {
}
private class ZombieEngineException : Exception("Engine failed to start")
}
}

View file

@ -157,6 +157,7 @@ class TtsController(context: Context) : Player.Listener {
bookTitle: String,
chapterTitle: String?,
coverImageUri: String?,
chapterIndex: Int? = null,
ttsMode: TtsPlaybackManager.TtsMode,
playbackSource: String = "READER",
authToken: String? = null
@ -179,6 +180,7 @@ class TtsController(context: Context) : Player.Listener {
putString(KEY_BOOK_TITLE, bookTitle)
putString(KEY_CHAPTER_TITLE, chapterTitle)
putString(KEY_COVER_IMAGE_URI, coverImageUri)
chapterIndex?.let { putInt(KEY_CHAPTER_INDEX, it) }
putString(KEY_TTS_MODE, ttsMode.name)
putString(KEY_PLAYBACK_SOURCE, playbackSource)
putString(KEY_AUTH_TOKEN, authToken)
@ -252,6 +254,8 @@ class TtsController(context: Context) : Player.Listener {
val isLoading = customState.getBoolean("isLoading", false)
val sessionFinished = customState.getBoolean("sessionFinished", false)
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 sourceCfi = mediaItemExtras?.getString("sourceCfi")
@ -270,6 +274,16 @@ class TtsController(context: Context) : Player.Listener {
if (isLoading) currentState.currentText else null
},
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,
sourceCfi = if (isPlaybackActive) {
sourceCfi
@ -338,4 +352,4 @@ fun rememberTtsController(): TtsController {
}
return controller
}
}

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_PLAYBACK_SOURCE = "KEY_PLAYBACK_SOURCE"
const val KEY_AUTH_TOKEN = "KEY_AUTH_TOKEN"
const val KEY_CHAPTER_INDEX = "KEY_CHAPTER_INDEX"
private const val PREFETCH_LOOKAHEAD = 3
@ -100,6 +101,8 @@ class TtsPlaybackManager(
val isLoading: Boolean = false,
val currentText: String? = null,
val errorMessage: String? = null,
val bookTitle: String? = null,
val chapterIndex: Int? = null,
val speakerId: String = DEFAULT_SPEAKER_ID,
val sourceCfi: String? = null,
val startOffsetInSource: Int = -1,
@ -189,6 +192,7 @@ class TtsPlaybackManager(
val bookTitle = args.getString(KEY_BOOK_TITLE)
val chapterTitle = args.getString(KEY_CHAPTER_TITLE)
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 playbackSource = args.getString(KEY_PLAYBACK_SOURCE)
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)
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 -> {
Timber.d("Received STOP command.")
@ -337,6 +341,7 @@ class TtsPlaybackManager(
bookTitle: String?,
chapterTitle: String?,
coverImageUri: String?,
chapterIndex: Int?,
ttsMode: TtsMode,
playbackSource: String?,
args: Bundle // Added this parameter
@ -375,6 +380,8 @@ class TtsPlaybackManager(
_ttsState.value = TtsState(
isLoading = true,
bookTitle = bookTitle,
chapterIndex = chapterIndex,
speakerId = speakerId,
playbackSource = playbackSource,
ttsMode = ttsMode.name
@ -857,6 +864,8 @@ class TtsPlaybackManager(
val bundle = Bundle().apply {
putBoolean("isLoading", state.isLoading)
putString("errorMessage", state.errorMessage)
putString("bookTitle", state.bookTitle)
putInt("chapterIndex", state.chapterIndex ?: -1)
putString("speakerId", state.speakerId)
putBoolean("sessionEndedByStop", state.sessionEndedByStop)
putString("currentWordSourceCfi", state.currentWordSourceCfi)
@ -897,4 +906,4 @@ class TtsPlaybackManager(
}
Timber.tag("TTS_CLOUD_DIAG").d("ExoPlayer playback state changed: $stateName")
}
}
}

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_line_height">Line Height</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. -->
<string name="label_original">Orig</string>
<!-- 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)
}
}