diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d1c4918..4b4ef44 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -57,8 +57,8 @@ android { applicationId = "com.aryan.reader" minSdk = 26 targetSdk = 35 - versionCode = 51 - versionName = "1.0.47" + versionCode = 52 + versionName = "1.0.48" resourceConfigurations += configuredAppLocaleTags() .map { it.toAndroidResourceConfiguration() } diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js index 3b6f716..b2477a8 100644 --- a/app/src/main/assets/epub_reader.js +++ b/app/src/main/assets/epub_reader.js @@ -1,5 +1,11 @@ // epub_reader.js (function () { + var READER_VERTICAL_JITTER_TAG = "EpubVerticalJitter"; + function logVerticalJitter(message) { + console.log(READER_VERTICAL_JITTER_TAG + ": " + message); + } + window.logReaderVerticalJitter = window.logReaderVerticalJitter || logVerticalJitter; + function applyMobileOptimizationsAndSelection() { var viewport = document.querySelector("meta[name=viewport]"); @@ -870,6 +876,30 @@ if (isNaN(newHorizontalMargin) || newHorizontalMargin < 0.0 || newHorizontalMargin > 3.0) newHorizontalMargin = 1.0; if (isNaN(newVerticalMargin) || newVerticalMargin < 0.0 || newVerticalMargin > 3.0) newVerticalMargin = 1.0; + var styleSignature = [ + newFontSize, + newLineHeight, + fontFamily || "", + textAlign || "", + newGap, + newImageSize, + newHorizontalMargin, + newVerticalMargin, + ].join("|"); + + if (window.__readerStyleSignature === styleSignature && dynamicStyleElement.innerHTML.trim().length > 0) { + logVerticalJitter( + "jsStyleSkip unchanged scrollY=" + + Math.round(window.scrollY || window.pageYOffset || 0) + + " signature=" + + styleSignature, + ); + return false; + } + + var scrollYBeforeStyle = Math.round(window.scrollY || window.pageYOffset || 0); + var scrollHeightBeforeStyle = Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)); + window.__readerStyleSignature = styleSignature; rememberReaderImageAnchors(); var fontCss = ""; @@ -964,6 +994,16 @@ dynamicStyleElement.innerHTML = [sizeCss, lineHeightCss, fontCss, alignCss, gapCss, imageCss, horizontalMarginCss].join("\n"); applyReaderImageAnchors(); setTimeout(applyReaderImageAnchors, 80); + logVerticalJitter( + "jsStyleApply scrollY=" + + scrollYBeforeStyle + + " scrollHeight=" + + scrollHeightBeforeStyle + + "->" + + Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)) + + " signature=" + + styleSignature, + ); setTimeout( function () { @@ -992,18 +1032,105 @@ } else if (window.reportScrollState) { setTimeout(window.reportScrollState, 60); } + return true; }; window.TOC_FRAGMENTS = window.TOC_FRAGMENTS || []; window.setTocFragments = function (jsonArray) { + var signature = JSON.stringify(jsonArray || []); + if (window.__readerTocFragmentsSignature === signature) { + logVerticalJitter("tocSkip unchanged count=" + ((jsonArray && jsonArray.length) || 0)); + return false; + } + window.__readerTocFragmentsSignature = signature; console.log("FRAG_NAV_DEBUG: window.setTocFragments called with " + jsonArray.length + " items."); + logVerticalJitter("tocApply count=" + jsonArray.length); window.TOC_FRAGMENTS = jsonArray; // Immediate audit and report window.auditTocFragments(); window.reportScrollState(); + return true; }; + var lastVerticalJitterScrollLogAt = 0; + var jitterProbeLastScrollY = null; + var jitterProbeLastScrollHeight = null; + var jitterProbeLastTime = 0; + var jitterProbeTrend = 0; + var jitterProbeLastAnomalyAt = 0; + var jitterProbeTouchActive = false; + var jitterProbeLastTouchY = null; + var jitterProbeExpectedScrollDirection = 0; + + function updateVerticalJitterScrollProbe(scrollY, scrollHeight, clientHeight) { + var now = Date.now(); + if (jitterProbeLastScrollY === null) { + jitterProbeLastScrollY = scrollY; + jitterProbeLastScrollHeight = scrollHeight; + jitterProbeLastTime = now; + return; + } + + var deltaY = scrollY - jitterProbeLastScrollY; + var deltaHeight = scrollHeight - jitterProbeLastScrollHeight; + var elapsedMs = now - jitterProbeLastTime; + var direction = deltaY > 0 ? 1 : deltaY < 0 ? -1 : 0; + var wasOppositeToTouch = + jitterProbeTouchActive && + jitterProbeExpectedScrollDirection !== 0 && + direction !== 0 && + direction !== jitterProbeExpectedScrollDirection; + var wasOppositeToTrend = + !jitterProbeTouchActive && + jitterProbeTrend !== 0 && + direction !== 0 && + direction !== jitterProbeTrend; + var isSmallReverse = Math.abs(deltaY) >= 2 && Math.abs(deltaY) <= 96; + var canLogAnomaly = now - jitterProbeLastAnomalyAt >= 120; + + if (deltaHeight !== 0 && canLogAnomaly) { + logVerticalJitter( + "scrollHeightChange y=" + + jitterProbeLastScrollY + + "->" + + scrollY + + " height=" + + jitterProbeLastScrollHeight + + "->" + + scrollHeight + + " touch=" + + jitterProbeTouchActive, + ); + jitterProbeLastAnomalyAt = now; + } else if ((wasOppositeToTouch || wasOppositeToTrend) && isSmallReverse && deltaHeight === 0 && canLogAnomaly) { + logVerticalJitter( + "scrollReverseSmall y=" + + jitterProbeLastScrollY + + "->" + + scrollY + + " dy=" + + deltaY + + " elapsedMs=" + + elapsedMs + + " expected=" + + jitterProbeExpectedScrollDirection + + " trend=" + + jitterProbeTrend + + " clientHeight=" + + clientHeight, + ); + jitterProbeLastAnomalyAt = now; + } + + if (direction !== 0) { + jitterProbeTrend = direction; + } + jitterProbeLastScrollY = scrollY; + jitterProbeLastScrollHeight = scrollHeight; + jitterProbeLastTime = now; + } + window.reportScrollState = function () { if (typeof PageInfoReporter !== "undefined" && PageInfoReporter.updateScrollState) { var scrollY = Math.round(window.scrollY || window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0); @@ -1047,6 +1174,21 @@ } PageInfoReporter.updateScrollState(scrollY, scrollHeight, clientHeight, activeFragment); + updateVerticalJitterScrollProbe(scrollY, scrollHeight, clientHeight); + var now = Date.now(); + if (now - lastVerticalJitterScrollLogAt >= 1000) { + logVerticalJitter( + "scrollReport y=" + + scrollY + + " scrollHeight=" + + scrollHeight + + " clientHeight=" + + clientHeight + + " activeFragment=" + + (activeFragment || ""), + ); + lastVerticalJitterScrollLogAt = now; + } } window.reportTopChunk(); @@ -1086,6 +1228,53 @@ ); }; + window.addEventListener( + "touchstart", + function (event) { + var touch = event.touches && event.touches[0]; + jitterProbeTouchActive = true; + jitterProbeLastTouchY = touch ? touch.clientY : null; + jitterProbeExpectedScrollDirection = 0; + }, + { passive: true }, + ); + + window.addEventListener( + "touchmove", + function (event) { + var touch = event.touches && event.touches[0]; + if (!touch || jitterProbeLastTouchY === null) return; + var fingerDeltaY = touch.clientY - jitterProbeLastTouchY; + if (Math.abs(fingerDeltaY) >= 1) { + jitterProbeExpectedScrollDirection = fingerDeltaY < 0 ? 1 : -1; + } + jitterProbeLastTouchY = touch.clientY; + }, + { passive: true }, + ); + + window.addEventListener( + "touchend", + function () { + jitterProbeLastTouchY = null; + setTimeout(function () { + jitterProbeTouchActive = false; + jitterProbeExpectedScrollDirection = 0; + }, 250); + }, + { passive: true }, + ); + + window.addEventListener( + "touchcancel", + function () { + jitterProbeTouchActive = false; + jitterProbeLastTouchY = null; + jitterProbeExpectedScrollDirection = 0; + }, + { passive: true }, + ); + let scrollThrottleTimeout = null; let lastScrollTime = 0; @@ -2364,6 +2553,11 @@ (function () { // --- VIRTUALIZATION LOGIC --- if (window.virtualization) return; + const logVerticalJitter = + window.logReaderVerticalJitter || + function (message) { + console.log("EpubVerticalJitter: " + message); + }; let currentBottomChunkIndex = 0; let totalChunks = 0; @@ -2378,6 +2572,16 @@ init: function (initialChunkIndex, total) { console.log(`Virtualization: Init with ${total} chunks. Anchor: ${initialChunkIndex}`); + logVerticalJitter( + "virtInit total=" + + total + + " anchor=" + + initialChunkIndex + + " scrollY=" + + Math.round(window.scrollY || window.pageYOffset || 0) + + " scrollHeight=" + + Math.round(Math.max(document.body.scrollHeight, document.documentElement.scrollHeight)), + ); this.totalChunks = total; this.chunksData = new Array(total).fill(null); this.chunkHeights = new Array(total).fill(0); @@ -2413,6 +2617,7 @@ if (entry.isIntersecting) { if (!this.chunksData[idx]) { + logVerticalJitter("virtRequestChunk idx=" + idx); if (window.ContentBridge && window.ContentBridge.requestChunk) { window.ContentBridge.requestChunk(idx); } @@ -2428,6 +2633,18 @@ if (div.getBoundingClientRect().top < 0) { scrollAdjust += (newHeight - oldHeight); } + logVerticalJitter( + "virtRestore idx=" + + idx + + " oldHeight=" + + Math.round(oldHeight) + + " newHeight=" + + Math.round(newHeight) + + " top=" + + Math.round(div.getBoundingClientRect().top) + + " pendingAdjust=" + + Math.round(scrollAdjust), + ); if (window.CURRENT_HIGHLIGHTS) { window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS); } @@ -2442,12 +2659,29 @@ div.style.height = oldHeight + "px"; div.innerHTML = ""; domChanged = true; + logVerticalJitter( + "virtUnload idx=" + + idx + + " placeholderHeight=" + + Math.round(oldHeight) + + " top=" + + Math.round(div.getBoundingClientRect().top), + ); } } }); if (scrollAdjust !== 0) { + let before = Math.round(window.scrollY || window.pageYOffset || 0); window.scrollBy(0, scrollAdjust); + logVerticalJitter( + "virtScrollAdjust dy=" + + Math.round(scrollAdjust) + + " scrollY=" + + before + + "->" + + Math.round(window.scrollY || window.pageYOffset || 0), + ); } if (domChanged && window.reportScrollState) { @@ -2464,6 +2698,14 @@ appendChunk: function (index, htmlContent) { console.log(`Virtualization: Receiving chunk ${index} from Kotlin`); + logVerticalJitter( + "virtAppend idx=" + + index + + " existingData=" + + Boolean(Array.isArray(this.chunksData) && this.chunksData[index]) + + " scrollY=" + + Math.round(window.scrollY || window.pageYOffset || 0), + ); if (Array.isArray(this.chunksData)) { this.chunksData[index] = htmlContent; @@ -2482,7 +2724,18 @@ } if (div.getBoundingClientRect().bottom < 0) { + let before = Math.round(window.scrollY || window.pageYOffset || 0); window.scrollBy(0, newHeight - oldHeight); + logVerticalJitter( + "virtAppendScrollAdjust idx=" + + index + + " dy=" + + Math.round(newHeight - oldHeight) + + " scrollY=" + + before + + "->" + + Math.round(window.scrollY || window.pageYOffset || 0), + ); } if (window.reportScrollState) { diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt index be66ca3..86d2353 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt @@ -99,6 +99,7 @@ import java.io.BufferedReader import java.io.InputStreamReader private const val TAG_LINK_NAV = "LINK_NAV" +private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter" private val READER_WEB_VIEW_JS_INTERFACES = arrayOf( "PageInfoReporter", "ProgressReporter", @@ -113,6 +114,68 @@ private val READER_WEB_VIEW_JS_INTERFACES = arrayOf( "LinkNavBridge" ) +private class WebViewRuntimeApplierState { + var fontCss: String? = null + var styleSignature: String? = null + var tocFragmentsJson: String? = null + var highlightsJson: String? = null + private var unchangedUpdateCount = 0 + private var pendingUpdateCount = 0 + private var lastUnchangedUpdateLogAt = 0L + private var lastPendingUpdateLogAt = 0L + + fun logApplied( + chapterTitle: String, + fontCssChanged: Boolean, + styleChanged: Boolean, + tocFragmentsChanged: Boolean, + highlightsChanged: Boolean + ) { + if (unchangedUpdateCount > 0) { + Timber.tag(TAG_VERTICAL_JITTER).d( + "androidUpdate resumeAfterUnchanged count=$unchangedUpdateCount chapter='$chapterTitle'" + ) + unchangedUpdateCount = 0 + } + if (pendingUpdateCount > 0) { + Timber.tag(TAG_VERTICAL_JITTER).d( + "androidUpdate resumeAfterPending count=$pendingUpdateCount chapter='$chapterTitle'" + ) + pendingUpdateCount = 0 + } + lastUnchangedUpdateLogAt = System.currentTimeMillis() + lastPendingUpdateLogAt = lastUnchangedUpdateLogAt + Timber.tag(TAG_VERTICAL_JITTER).d( + "androidUpdate applied chapter='$chapterTitle' fontCss=$fontCssChanged " + + "style=$styleChanged toc=$tocFragmentsChanged highlights=$highlightsChanged" + ) + } + + fun logUnchanged(chapterTitle: String) { + unchangedUpdateCount++ + val now = System.currentTimeMillis() + if (now - lastUnchangedUpdateLogAt >= 1000L) { + Timber.tag(TAG_VERTICAL_JITTER).d( + "androidUpdate unchanged count=$unchangedUpdateCount chapter='$chapterTitle'" + ) + unchangedUpdateCount = 0 + lastUnchangedUpdateLogAt = now + } + } + + fun logPending(chapterTitle: String) { + pendingUpdateCount++ + val now = System.currentTimeMillis() + if (now - lastPendingUpdateLogAt >= 1000L) { + Timber.tag(TAG_VERTICAL_JITTER).d( + "androidUpdate pendingPageLoad count=$pendingUpdateCount chapter='$chapterTitle'" + ) + pendingUpdateCount = 0 + lastPendingUpdateLogAt = now + } + } +} + private fun WebView.releaseReaderResources() { try { stopLoading() @@ -523,6 +586,9 @@ fun ChapterWebView( currentFontFamily, currentTextAlign ) { + val runtimeApplierState = remember { WebViewRuntimeApplierState() } + var isReaderRuntimeReady by remember { mutableStateOf(false) } + AndroidView( factory = { ctx -> Timber.d( @@ -556,6 +622,7 @@ fun ChapterWebView( }).apply { localWebViewRef = this onWebViewInstanceCreated(this) + val debugWebViewId = System.identityHashCode(this).toString(16) addJavascriptInterface( PageInfoBridge { scrollY, scrollHeight, clientHeight, activeFragmentId -> this.post { onScrollStateUpdate(scrollY, scrollHeight, clientHeight, activeFragmentId) } @@ -671,6 +738,11 @@ fun ChapterWebView( .d("JS -> ${message.substringAfter("FRAG_NAV_DEBUG: ")}") } + message.startsWith("$TAG_VERTICAL_JITTER:") -> { + Timber.tag(TAG_VERTICAL_JITTER) + .d("webView=$debugWebViewId chapter='$chapterTitle' JS -> ${message.substringAfter("$TAG_VERTICAL_JITTER: ")}") + } + else -> { Timber.d( "[${it.sourceId()}:${it.lineNumber()}] ${it.message()}" @@ -818,11 +890,11 @@ fun ChapterWebView( ) view?.evaluateJavascript( - "javascript:window.HighlightBridgeHelper.restoreHighlights('${ + "javascript:window.CURRENT_HIGHLIGHTS = '${ escapeJsString( highlightsJson ) - }');", null + }'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", null ) val fontCss = getFontCssInjection().replace("\n", " ") @@ -845,6 +917,20 @@ fun ChapterWebView( currentFontFamily.fontFamilyName } + runtimeApplierState.fontCss = combinedCss + runtimeApplierState.styleSignature = listOf( + currentFontSize, + currentLineHeight, + fontNameForJs, + currentTextAlign.cssValue, + currentParagraphGap, + currentImageSize, + currentHorizontalMargin, + currentVerticalMargin + ).joinToString(separator = "|") + runtimeApplierState.tocFragmentsJson = fragmentsJson + runtimeApplierState.highlightsJson = highlightsJson + view?.evaluateJavascript( "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);", null @@ -956,6 +1042,9 @@ fun ChapterWebView( "javascript:if(window.getSelection) window.getSelection().removeAllRanges();", null ) + isReaderRuntimeReady = true + Timber.tag(TAG_VERTICAL_JITTER) + .d("androidPageFinished runtimeReady webView=$debugWebViewId chapter='$chapterTitle'") } } settings.apply { @@ -983,6 +1072,7 @@ fun ChapterWebView( }, modifier = Modifier.fillMaxSize(), onRelease = { releasedWebView -> + isReaderRuntimeReady = false if (localWebViewRef === releasedWebView) { localWebViewRef = null } @@ -992,42 +1082,84 @@ fun ChapterWebView( releasedWebView.releaseReaderResources() }, update = { webView -> - Timber.d("WebView update. Setting Font: ${currentFontFamily.fontFamilyName}") localWebViewRef = webView onWebViewInstanceCreated(webView) - val fontCss = getFontCssInjection().replace("\n", " ") - val customFontCss = if (customFontPath != null) { - "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" - } else "" - val combinedCss = "$fontCss $customFontCss" - val injectFontJs = - "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";" - webView.evaluateJavascript("javascript:$injectFontJs", null) - val fontNameForJs = if (customFontPath != null) { - "CustomFont" - } else if (currentFontFamily == ReaderFont.ORIGINAL) { - "" + if (!isReaderRuntimeReady) { + runtimeApplierState.logPending(chapterTitle) } else { - currentFontFamily.fontFamilyName + val fontCss = getFontCssInjection().replace("\n", " ") + val customFontCss = if (customFontPath != null) { + "@font-face { font-family: 'CustomFont'; src: url('file://$customFontPath'); }" + } else "" + val combinedCss = "$fontCss $customFontCss" + val fontNameForJs = if (customFontPath != null) { + "CustomFont" + } else if (currentFontFamily == ReaderFont.ORIGINAL) { + "" + } else { + currentFontFamily.fontFamilyName + } + val fragmentsJson = org.json.JSONArray(tocFragments).toString() + val styleSignature = listOf( + currentFontSize, + currentLineHeight, + fontNameForJs, + currentTextAlign.cssValue, + currentParagraphGap, + currentImageSize, + currentHorizontalMargin, + currentVerticalMargin + ).joinToString(separator = "|") + val fontCssChanged = runtimeApplierState.fontCss != combinedCss + val styleChanged = runtimeApplierState.styleSignature != styleSignature + val tocFragmentsChanged = runtimeApplierState.tocFragmentsJson != fragmentsJson + val highlightsChanged = runtimeApplierState.highlightsJson != highlightsJson + + if (fontCssChanged || styleChanged || tocFragmentsChanged || highlightsChanged) { + runtimeApplierState.logApplied( + chapterTitle = chapterTitle, + fontCssChanged = fontCssChanged, + styleChanged = styleChanged, + tocFragmentsChanged = tocFragmentsChanged, + highlightsChanged = highlightsChanged + ) + } else { + runtimeApplierState.logUnchanged(chapterTitle) + } + + if (fontCssChanged) { + runtimeApplierState.fontCss = combinedCss + val injectFontJs = + "var style = document.getElementById('injectedFonts'); if(!style) { style = document.createElement('style'); style.id='injectedFonts'; document.head.appendChild(style); } style.innerHTML = \"$combinedCss\";" + webView.evaluateJavascript("javascript:$injectFontJs", null) + } + + if (tocFragmentsChanged) { + runtimeApplierState.tocFragmentsJson = fragmentsJson + Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson") + webView.evaluateJavascript( + "javascript:window.setTocFragments($fragmentsJson);", + null + ) + } + + if (styleChanged) { + runtimeApplierState.styleSignature = styleSignature + webView.evaluateJavascript( + "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);", + null + ) + } + + if (highlightsChanged) { + runtimeApplierState.highlightsJson = highlightsJson + val escapedHighlights = escapeJsString(highlightsJson) + webView.evaluateJavascript( + "javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", + null + ) + } } - val fragmentsJson = org.json.JSONArray(tocFragments).toString() - Timber.tag("FRAG_NAV_DEBUG").d("Injecting TOC_FRAGMENTS via setter: $fragmentsJson") - - webView.evaluateJavascript( - "javascript:window.setTocFragments($fragmentsJson);", - null - ) - - webView.evaluateJavascript( - "javascript:window.updateReaderStyles($currentFontSize, $currentLineHeight, '$fontNameForJs', '${currentTextAlign.cssValue}', $currentParagraphGap, $currentImageSize, $currentHorizontalMargin, $currentVerticalMargin);", - null - ) - - val escapedHighlights = escapeJsString(highlightsJson) - webView.evaluateJavascript( - "javascript:window.CURRENT_HIGHLIGHTS = '${escapedHighlights}'; window.HighlightBridgeHelper.restoreHighlights(window.CURRENT_HIGHLIGHTS);", - null - ) } ) } diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt index 5e98b71..61cc316 100644 --- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt +++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt @@ -252,6 +252,7 @@ private const val TTS_LOCATE_REASON_LIFECYCLE_RESUME = "lifecycle_resume" private const val TTS_LOCATE_REASON_OVERLAY = "overlay" private const val TAG_LINK_NAV = "LINK_NAV" +private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter" private const val TAG_STABLE_PAGE_NAV = "StablePageNav" private const val TAG_PAGINATED_HIGHLIGHT_DIAG = "PaginatedHighlightDiag" @@ -3574,6 +3575,9 @@ fun EpubReaderHost( """.trimIndent() val chapterToRender = chapters[targetChapterIndex] + fun isCurrentRenderedChapter(): Boolean = + targetChapterIndex == currentChapterIndex + val chapterKeyForWebView = remember( chapterToRender.htmlFilePath, @@ -3629,8 +3633,8 @@ fun EpubReaderHost( } } - val currentChapterTocFragments = remember(epubBook.tableOfContents, currentChapterIndex) { - val chapterPath = chapters.getOrNull(currentChapterIndex)?.absPath + val currentChapterTocFragments = remember(epubBook.tableOfContents, targetChapterIndex) { + val chapterPath = chapters.getOrNull(targetChapterIndex)?.absPath epubBook.tableOfContents .filter { it.absolutePath == chapterPath && it.fragmentId != null } .mapNotNull { it.fragmentId } @@ -3683,42 +3687,48 @@ fun EpubReaderHost( } }, onChapterInitiallyScrolled = { - val wasCfiScroll = cfiToLoad != null - Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") - logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll") - initialScrollTargetForChapter = null - cfiToLoad = null - fragmentToLoad = null - Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") - isWebViewReady = true - - if (wasCfiScroll) { - scope.launch { - delay(1000L) - isChapterReadyForBookmarkCheck = true - Timber.d("Auto-save enabled after CFI scroll delay.") - } + if (!isCurrentRenderedChapter()) { + Timber.tag(TAG_VERTICAL_JITTER).d( + "ignored stale initiallyScrolled rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'" + ) } else { - isChapterReadyForBookmarkCheck = true - Timber.d("Auto-save enabled immediately.") - } + val wasCfiScroll = cfiToLoad != null + Timber.tag("NavDiag").d("onChapterInitiallyScrolled for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") + logTtsChapterDiag("Chapter initially scrolled. targetChapter=$targetChapterIndex wasCfiScroll=$wasCfiScroll") + initialScrollTargetForChapter = null + cfiToLoad = null + fragmentToLoad = null + Timber.d("Initial scroll consumed for chapter $targetChapterIndex. Was CFI scroll: $wasCfiScroll") + isWebViewReady = true - if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) { - Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).") - logTtsChapterDiag("Auto-starting TTS extraction for chapter load") - hasRequestedExtractionForThisChapter = true - scope.launch { - delay(200) - webViewRefForTts?.evaluateJavascript( - "javascript:TtsBridgeHelper.extractAndRelayText();", - null - ) + if (wasCfiScroll) { + scope.launch { + delay(1000L) + isChapterReadyForBookmarkCheck = true + Timber.d("Auto-save enabled after CFI scroll delay.") + } + } else { + isChapterReadyForBookmarkCheck = true + Timber.d("Auto-save enabled immediately.") } - } - if (isAutoScrollModeActive && isAutoScrollPlaying) { - Timber.d("Continuing Auto-Scroll for new chapter with delay.") - triggerAutoScrollTempPause(1000L) + if (ttsShouldStartOnChapterLoad && !hasRequestedExtractionForThisChapter) { + Timber.d("Auto-starting TTS for new chapter ($targetChapterIndex).") + logTtsChapterDiag("Auto-starting TTS extraction for chapter load") + hasRequestedExtractionForThisChapter = true + scope.launch { + delay(200) + webViewRefForTts?.evaluateJavascript( + "javascript:TtsBridgeHelper.extractAndRelayText();", + null + ) + } + } + + if (isAutoScrollModeActive && isAutoScrollPlaying) { + Timber.d("Continuing Auto-Scroll for new chapter with delay.") + triggerAutoScrollTempPause(1000L) + } } }, onTap = { @@ -3886,22 +3896,28 @@ fun EpubReaderHost( }, tocFragments = currentChapterTocFragments, onScrollStateUpdate = { scrollY, scrollHeight, clientHeight, fragId -> - currentScrollYPosition = scrollY - currentScrollHeightValue = scrollHeight - currentClientHeightValue = clientHeight + if (!isCurrentRenderedChapter()) { + Timber.tag(TAG_VERTICAL_JITTER).d( + "ignored stale scrollState rendered=$targetChapterIndex current=$currentChapterIndex y=$scrollY height=$scrollHeight chapter='${chapterToRender.title}'" + ) + } else { + currentScrollYPosition = scrollY + currentScrollHeightValue = scrollHeight + currentClientHeightValue = clientHeight - if (activeFragmentId != fragId) { - Timber.tag("FRAG_NAV_DEBUG").d("State updated to: $fragId") - activeFragmentId = fragId - } + if (activeFragmentId != fragId) { + Timber.tag("FRAG_NAV_DEBUG").d("State updated to: $fragId") + activeFragmentId = fragId + } - if (volumeScrollEnabled && !searchState.isSearchActive) { - volumeScrollFocusDebounceJob.value?.cancel() - volumeScrollFocusDebounceJob.value = scope.launch { - delay(300L) - if (isActive) { - containerFocusRequester.requestFocus() - Timber.d("Refocusing container after scroll to re-enable volume keys.") + if (volumeScrollEnabled && !searchState.isSearchActive) { + volumeScrollFocusDebounceJob.value?.cancel() + volumeScrollFocusDebounceJob.value = scope.launch { + delay(300L) + if (isActive) { + containerFocusRequester.requestFocus() + Timber.d("Refocusing container after scroll to re-enable volume keys.") + } } } } @@ -4059,7 +4075,13 @@ fun EpubReaderHost( } }, onWebViewInstanceCreated = { webView -> - webViewRefForTts = webView + if (isCurrentRenderedChapter()) { + webViewRefForTts = webView + } else { + Timber.tag(TAG_VERTICAL_JITTER).d( + "ignored stale webViewRef rendered=$targetChapterIndex current=$currentChapterIndex chapter='${chapterToRender.title}'" + ) + } webView.evaluateJavascript( "javascript:window.setViewportPadding(${topPaddingPx}, 0);", null @@ -4386,25 +4408,37 @@ fun EpubReaderHost( ) }, onTopChunkUpdated = { chunkIndex -> - topVisibleChunkIndex = chunkIndex + if (isCurrentRenderedChapter()) { + topVisibleChunkIndex = chunkIndex + } else { + Timber.tag(TAG_VERTICAL_JITTER).d( + "ignored stale topChunk rendered=$targetChapterIndex current=$currentChapterIndex chunk=$chunkIndex chapter='${chapterToRender.title}'" + ) + } }, initialHtmlContent = initialHtml, baseUrl = baseUrl, totalChunks = chapterChunks.size, initialChunkIndex = loadUpToChunkIndex, onChunkRequested = { index -> - val chunkContent = chapterChunks.getOrNull(index) - if (chunkContent != null) { - loadedChunkCount = - max(loadedChunkCount, index + 1) - val escapedContent = - escapeJsString(chunkContent) - val jsCommand = - "javascript:window.virtualization.appendChunk($index, '$escapedContent');" - webViewRefForTts?.evaluateJavascript( - jsCommand, - null + if (!isCurrentRenderedChapter()) { + Timber.tag(TAG_VERTICAL_JITTER).d( + "ignored stale chunkRequest rendered=$targetChapterIndex current=$currentChapterIndex chunk=$index chapter='${chapterToRender.title}'" ) + } else { + val chunkContent = chapterChunks.getOrNull(index) + if (chunkContent != null) { + loadedChunkCount = + max(loadedChunkCount, index + 1) + val escapedContent = + escapeJsString(chunkContent) + val jsCommand = + "javascript:window.virtualization.appendChunk($index, '$escapedContent');" + webViewRefForTts?.evaluateJavascript( + jsCommand, + null + ) + } } }, ) diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt index cc916dd..13026f8 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfDocumentUtils.kt @@ -13,6 +13,8 @@ import android.print.PrintDocumentInfo import android.provider.OpenableColumns import android.util.LruCache import androidx.core.graphics.createBitmap +import com.aryan.reader.pdf.data.PdfAnnotation +import com.aryan.reader.pdf.data.PdfTextBox import io.legere.pdfiumandroid.suspend.PdfiumCoreKt import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -135,6 +137,25 @@ internal fun getSuggestedFilename(originalName: String?, isAnnotated: Boolean): return "${safeBase}${suffix}_${shortId}.pdf" } +internal fun hasExportablePdfAnnotations( + annotations: Map>, + textBoxes: List, + highlights: List +): Boolean { + return annotations.any { (_, pageAnnotations) -> pageAnnotations.isNotEmpty() } || + textBoxes.isNotEmpty() || + highlights.isNotEmpty() +} + +internal fun shouldShowPdfAnnotationExportChoice( + sidecarsReady: Boolean, + annotations: Map>, + textBoxes: List, + highlights: List +): Boolean { + return !sidecarsReady || hasExportablePdfAnnotations(annotations, textBoxes, highlights) +} + internal fun getFastFileId(context: Context, uri: Uri): String { var result = uri.toString() try { diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index 320a0c9..ee4f7e9 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -2256,6 +2256,91 @@ fun PdfViewerScreen( var showShareDialog by remember { mutableStateOf(false) } var showSaveDialog by remember { mutableStateOf(false) } var isShareLoading by remember { mutableStateOf(false) } + val shouldShowAnnotationExportChoice = shouldShowPdfAnnotationExportChoice( + sidecarsReady = sidecarsReadyForCurrentBook, + annotations = visibleAllAnnotations, + textBoxes = visibleTextBoxes, + highlights = visibleUserHighlights + ) + + val launchOriginalSaveCopy: () -> Unit = { + pendingSaveMode = SaveMode.ORIGINAL + val suggestedName = getSuggestedFilename( + originalFileName, isAnnotated = false + ) + saveLauncher.launch(suggestedName) + } + + val launchAnnotatedSaveCopy: () -> Unit = { + pendingSaveMode = SaveMode.ANNOTATED + val suggestedName = getSuggestedFilename( + originalFileName, isAnnotated = true + ) + saveLauncher.launch(suggestedName) + } + + val shareOriginalPdf: () -> Unit = { + isShareLoading = true + val filename = getSuggestedFilename( + originalFileName, isAnnotated = false + ) + coroutineScope.launch { + try { + viewModel.sharePdf( + activityContext = context, + sourceUri = pdfUri, + annotations = emptyMap(), + includeAnnotations = false, + filename = filename + ) + } finally { + isShareLoading = false + } + } + } + + val shareAnnotatedPdf: () -> Unit = { + isShareLoading = true + Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}") + val filename = getSuggestedFilename( + originalFileName, isAnnotated = true + ) + coroutineScope.launch { + try { + val currentRichTextLayouts = richTextController?.pageLayouts + + viewModel.sharePdf( + activityContext = context, + sourceUri = effectivePdfUri, + annotations = visibleAllAnnotations, + richTextPageLayouts = currentRichTextLayouts, + textBoxes = visibleTextBoxes, + highlights = visibleUserHighlights, + includeAnnotations = true, + filename = filename, + bookId = currentBookId + ) + } finally { + isShareLoading = false + } + } + } + + val requestSaveCopy: () -> Unit = { + if (shouldShowAnnotationExportChoice) { + showSaveDialog = true + } else { + launchOriginalSaveCopy() + } + } + + val requestShare: () -> Unit = { + if (shouldShowAnnotationExportChoice) { + showShareDialog = true + } else { + shareOriginalPdf() + } + } var ocrUsedForCurrentPageTts by remember { mutableStateOf(false) } @@ -5358,8 +5443,8 @@ fun PdfViewerScreen( } } }, - onShare = { showShareDialog = true }, - onSaveCopy = { showSaveDialog = true }, + onShare = requestShare, + onSaveCopy = requestSaveCopy, onPrint = onPrintDocument, onTabClick = { tabBookId -> coroutineScope.launch { @@ -7030,11 +7115,7 @@ fun PdfViewerScreen( TextButton( onClick = { showSaveDialog = false - pendingSaveMode = SaveMode.ANNOTATED - val suggestedName = getSuggestedFilename( - originalFileName, isAnnotated = true - ) - saveLauncher.launch(suggestedName) + launchAnnotatedSaveCopy() }) { Text(stringResource(R.string.action_with_annotations)) } } @@ -7044,11 +7125,7 @@ fun PdfViewerScreen( TextButton( onClick = { showSaveDialog = false - pendingSaveMode = SaveMode.ORIGINAL - val suggestedName = getSuggestedFilename( - originalFileName, isAnnotated = false - ) - saveLauncher.launch(suggestedName) + launchOriginalSaveCopy() }) { Text(stringResource(R.string.action_original)) } Spacer(Modifier.width(8.dp)) @@ -7072,27 +7149,7 @@ fun PdfViewerScreen( TextButton( onClick = { showShareDialog = false - isShareLoading = true - Timber.tag("PdfExportDebug").i("SHARE TRIGGERED: userHighlights count: ${visibleUserHighlights.size}") - val filename = getSuggestedFilename( - originalFileName, isAnnotated = true - ) - coroutineScope.launch { - val currentRichTextLayouts = richTextController?.pageLayouts - - viewModel.sharePdf( - activityContext = context, - sourceUri = effectivePdfUri, - annotations = visibleAllAnnotations, - richTextPageLayouts = currentRichTextLayouts, - textBoxes = visibleTextBoxes, - highlights = visibleUserHighlights, - includeAnnotations = true, - filename = filename, - bookId = currentBookId - ) - isShareLoading = false - } + shareAnnotatedPdf() }) { Text(stringResource(R.string.action_with_annotations)) } } @@ -7102,20 +7159,7 @@ fun PdfViewerScreen( TextButton( onClick = { showShareDialog = false - isShareLoading = true - val filename = getSuggestedFilename( - originalFileName, isAnnotated = false - ) - coroutineScope.launch { - viewModel.sharePdf( - activityContext = context, - sourceUri = pdfUri, - annotations = emptyMap(), - includeAnnotations = false, - filename = filename - ) - isShareLoading = false - } + shareOriginalPdf() }) { Text(stringResource(R.string.action_original)) } Spacer(Modifier.width(8.dp)) TextButton(onClick = { showShareDialog = false }) { diff --git a/app/src/main/java/com/aryan/reader/tts/TtsController.kt b/app/src/main/java/com/aryan/reader/tts/TtsController.kt index 5eb0f10..ca694ba 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsController.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsController.kt @@ -30,8 +30,6 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.content.edit -import androidx.core.net.toUri -import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.session.MediaController @@ -54,19 +52,6 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -private const val SETTINGS_PREFS_NAME = "epub_reader_settings" -private const val TTS_SPEAKER_KEY = "tts_speaker" - -private fun saveSpeaker(context: Context, speakerId: String) { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - prefs.edit { putString(TTS_SPEAKER_KEY, speakerId) } -} - -private fun loadSpeaker(context: Context): String { - val prefs = context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) - return prefs.getString(TTS_SPEAKER_KEY, DEFAULT_SPEAKER_ID) ?: DEFAULT_SPEAKER_ID -} - @OptIn(UnstableApi::class) fun loadTtsMode(context: Context): TtsPlaybackManager.TtsMode { val prefs = context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) @@ -101,7 +86,7 @@ class TtsController(context: Context) : Player.Listener { private var pollingJob: Job? = null init { - val initialSpeakerId = loadSpeaker(this.context) + val initialSpeakerId = loadTtsSpeaker(this.context) _ttsState.value = _ttsState.value.copy(speakerId = initialSpeakerId) } @@ -236,11 +221,12 @@ class TtsController(context: Context) : Player.Listener { @Suppress("unused") fun changeSpeaker(speakerId: String) { Timber.d("UI sending CHANGE_SPEAKER command.") - saveSpeaker(context, speakerId) - _ttsState.value = _ttsState.value.copy(speakerId = speakerId) + val safeSpeakerId = normalizeTtsSpeakerId(speakerId) + saveTtsSpeaker(context, safeSpeakerId) + _ttsState.value = _ttsState.value.copy(speakerId = safeSpeakerId) val args = Bundle().apply { - putString(KEY_SPEAKER_ID, speakerId) + putString(KEY_SPEAKER_ID, safeSpeakerId) } mediaController?.sendCustomCommand(CHANGE_SPEAKER_COMMAND, args) } diff --git a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt index fa58d1c..09179fb 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsPlaybackManager.kt @@ -130,15 +130,22 @@ class TtsPlaybackManager( val ttsMode: String = TtsMode.CLOUD.name ) - private val _ttsState = MutableStateFlow(TtsState()) + private val initialSpeakerId = loadTtsSpeaker(appContext) + private val initialTtsMode = loadTtsMode(appContext) + private val _ttsState = MutableStateFlow( + TtsState( + speakerId = initialSpeakerId, + ttsMode = initialTtsMode.name + ) + ) private var textChunks: List = emptyList() private val audioFiles = java.util.concurrent.ConcurrentHashMap() - private var currentSpeakerId = DEFAULT_SPEAKER_ID + private var currentSpeakerId = initialSpeakerId private var bookTitle: String? = null private var chapterTitle: String? = null private var coverImageUri: String? = null - private var currentTtsMode = TtsMode.CLOUD + private var currentTtsMode = initialTtsMode private var chapterIndex: Int? = null private var totalChapters: Int? = null @@ -157,6 +164,12 @@ class TtsPlaybackManager( fun setMediaSession(session: MediaSession) { this.mediaSession = session + session.setCustomLayout( + listOf( + createStateButton(_ttsState.value), + createStopCommandButton() + ) + ) } override fun onConnect( @@ -422,8 +435,10 @@ class TtsPlaybackManager( onPlaybackSessionPreparing(bookTitle, chapterTitle) + val effectiveSpeakerId = normalizeTtsSpeakerId(speakerId) + textChunks = chunks - currentSpeakerId = speakerId + currentSpeakerId = effectiveSpeakerId currentTtsMode = ttsMode this.bookTitle = bookTitle this.chapterTitle = chapterTitle @@ -443,7 +458,7 @@ class TtsPlaybackManager( currentChunkIndex = -1, totalChunks = chunks.size, bookProgressPercent = calculateBookProgressPercent(-1), - speakerId = speakerId, + speakerId = effectiveSpeakerId, playbackSource = playbackSource, ttsMode = ttsMode.name, currentText = if (continueSession) _ttsState.value.currentText else null @@ -480,10 +495,12 @@ class TtsPlaybackManager( } private fun handleChangeSpeaker(newSpeakerId: String) { - if (currentSpeakerId == newSpeakerId) return - currentSpeakerId = newSpeakerId - _ttsState.value = _ttsState.value.copy(speakerId = newSpeakerId) - Timber.d("Speaker changed to $newSpeakerId (pending next start)") + val safeSpeakerId = normalizeTtsSpeakerId(newSpeakerId) + if (currentSpeakerId == safeSpeakerId) return + currentSpeakerId = safeSpeakerId + saveTtsSpeaker(appContext, safeSpeakerId) + _ttsState.value = _ttsState.value.copy(speakerId = safeSpeakerId) + Timber.d("Speaker changed to $safeSpeakerId (pending next start)") } private fun currentChunkIndexFromPlayer(): Int { @@ -681,7 +698,11 @@ class TtsPlaybackManager( preparationJob?.cancel() wordTrackingJob?.cancel() if (clearState) { - val finalState = TtsState(sessionEndedByStop = userInitiated) + val finalState = TtsState( + sessionEndedByStop = userInitiated, + speakerId = currentSpeakerId, + ttsMode = currentTtsMode.name + ) _ttsState.value = finalState mediaSession?.let { session -> val layout = listOf( diff --git a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt index 46cc2a4..cb403f3 100644 --- a/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt +++ b/app/src/main/java/com/aryan/reader/tts/TtsUtils.kt @@ -25,6 +25,7 @@ import androidx.annotation.OptIn import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.core.content.edit import androidx.media3.common.util.UnstableApi import com.aryan.reader.BuildConfig import kotlinx.coroutines.CoroutineScope @@ -42,6 +43,8 @@ const val googleCloudWorkerTtsUrl = BuildConfig.TTS_WORKER_URL const val TTS_CHUNK_MAX_LENGTH = 250 const val DEFAULT_SPEAKER_ID = "Aoede" +internal const val TTS_SETTINGS_PREFS_NAME = "epub_reader_settings" +internal const val TTS_SPEAKER_KEY = "tts_speaker" data class GeminiVoice(val id: String, val name: String, val description: String) @@ -78,6 +81,21 @@ val GEMINI_TTS_SPEAKERS = listOf( GeminiVoice("Sulafat", "Sulafat", "Warn, Middle pitch"), ) +internal fun normalizeTtsSpeakerId(speakerId: String?): String { + val cleanSpeakerId = speakerId?.takeIf { it.isNotBlank() } ?: return DEFAULT_SPEAKER_ID + return cleanSpeakerId.takeIf { id -> GEMINI_TTS_SPEAKERS.any { it.id == id } } ?: DEFAULT_SPEAKER_ID +} + +internal fun saveTtsSpeaker(context: Context, speakerId: String) { + val prefs = context.getSharedPreferences(TTS_SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + prefs.edit { putString(TTS_SPEAKER_KEY, normalizeTtsSpeakerId(speakerId)) } +} + +internal fun loadTtsSpeaker(context: Context): String { + val prefs = context.getSharedPreferences(TTS_SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + return normalizeTtsSpeakerId(prefs.getString(TTS_SPEAKER_KEY, DEFAULT_SPEAKER_ID)) +} + data class TtsChapterCacheInfo( val chapterTitle: String, val chunkCount: Int, diff --git a/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt b/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt index c65b4b7..abb7f79 100644 --- a/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt +++ b/app/src/test/java/com/aryan/reader/pdf/PdfReaderCoreLogicTest.kt @@ -6,6 +6,7 @@ import android.graphics.Rect import androidx.compose.ui.graphics.Color import com.aryan.reader.pdf.data.PdfAnnotation import com.aryan.reader.pdf.data.PdfAnnotationRepository +import com.aryan.reader.pdf.data.PdfTextBox import com.aryan.reader.pdf.data.VirtualPage import com.aryan.reader.pdf.ocr.OcrBlock import com.aryan.reader.pdf.ocr.OcrElement @@ -13,6 +14,7 @@ import com.aryan.reader.pdf.ocr.OcrLine import com.aryan.reader.pdf.ocr.OcrResult import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -107,6 +109,62 @@ class PdfReaderCoreLogicTest { assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf"))) } + @Test + fun `pdf export choice is hidden when loaded sidecars have no annotations`() { + assertFalse( + shouldShowPdfAnnotationExportChoice( + sidecarsReady = true, + annotations = mapOf(0 to emptyList()), + textBoxes = emptyList(), + highlights = emptyList() + ) + ) + } + + @Test + fun `pdf export choice is shown when exportable annotations exist`() { + val inkAnnotation = PdfAnnotation( + type = AnnotationType.INK, + inkType = InkType.PEN, + pageIndex = 0, + points = listOf(PdfPoint(0.1f, 0.2f)), + color = Color.Black, + strokeWidth = 0.01f + ) + val textBox = PdfTextBox( + id = "box", + pageIndex = 0, + relativeBounds = androidx.compose.ui.geometry.Rect(0.1f, 0.1f, 0.4f, 0.2f), + text = "note", + color = Color.Black, + backgroundColor = Color.Transparent, + fontSize = 16f + ) + val highlight = PdfUserHighlight( + pageIndex = 0, + bounds = listOf(RectF(0.1f, 0.1f, 0.4f, 0.2f)), + color = PdfHighlightColor.YELLOW, + text = "selected", + range = 0 to 8 + ) + + assertTrue(shouldShowPdfAnnotationExportChoice(true, mapOf(0 to listOf(inkAnnotation)), emptyList(), emptyList())) + assertTrue(shouldShowPdfAnnotationExportChoice(true, emptyMap(), listOf(textBox), emptyList())) + assertTrue(shouldShowPdfAnnotationExportChoice(true, emptyMap(), emptyList(), listOf(highlight))) + } + + @Test + fun `pdf export choice remains available until sidecars are loaded`() { + assertTrue( + shouldShowPdfAnnotationExportChoice( + sidecarsReady = false, + annotations = emptyMap(), + textBoxes = emptyList(), + highlights = emptyList() + ) + ) + } + @Test fun `pdfRenderPageId separates same page across documents`() { val firstDocumentPage = pdfRenderPageId("book-a", 0, VirtualPage.PdfPage(0)) diff --git a/app/src/test/java/com/aryan/reader/tts/TtsSpeakerPreferencesTest.kt b/app/src/test/java/com/aryan/reader/tts/TtsSpeakerPreferencesTest.kt new file mode 100644 index 0000000..85f084d --- /dev/null +++ b/app/src/test/java/com/aryan/reader/tts/TtsSpeakerPreferencesTest.kt @@ -0,0 +1,41 @@ +package com.aryan.reader.tts + +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment + +@RunWith(RobolectricTestRunner::class) +class TtsSpeakerPreferencesTest { + private lateinit var context: Context + + @Before + fun setUp() { + context = RuntimeEnvironment.getApplication() + context.getSharedPreferences(TTS_SETTINGS_PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .clear() + .commit() + } + + @Test + fun `tts speaker preference saves and loads selected ai voice`() { + saveTtsSpeaker(context, "Kore") + + assertEquals("Kore", loadTtsSpeaker(context)) + } + + @Test + fun `tts speaker preference falls back to default for blank or unknown voices`() { + assertEquals(DEFAULT_SPEAKER_ID, loadTtsSpeaker(context)) + + saveTtsSpeaker(context, "") + assertEquals(DEFAULT_SPEAKER_ID, loadTtsSpeaker(context)) + + saveTtsSpeaker(context, "MissingVoice") + assertEquals(DEFAULT_SPEAKER_ID, loadTtsSpeaker(context)) + } +}