Update v1.0.49 (#330)

* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE

* Refactored desktop reader screens and state management into specialized components

* Added image gallery to reader sidebar and refactored desktop PDF UI components

* Implemented EPUB image gallery

* Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic

* Standardized UI styling and refactored app navigation layout

* Implemented auto-hiding reader chrome and activity tracking in desktop

* Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support.

* Added PPTX support for desktop and refactored parsing into a shared module

* Implement paid AI features and account management for the desktop application.

* Implement AI Hub and enhanced Cloud TTS integration for Desktop

* Implement streaming support for AI definition and summarization features

* Implement support for password-protected PDFs and file actions in the desktop reader.

* Implement cloud synchronization for desktop using Firestore and Google Drive

* Implement PDF reflow and "Text View" for the desktop reader

* Refactor OPDS logic to use SharedOpdsController

* Optimize PDF tile rendering performance

* Implement two-page spread support for PDF pagination

* Implement two-page spread support for the PDF viewer

* Improved shared spread zoom in PDF viewer

* Improve PDF spread navigation with fling support and configurable page gaps

* Add brightness control to PDF and EPUB readers

* Refactor folder synchronization to use shared logic engine

* Implement safe string formatting and validation for localized resources

* Implement TTS chunk skip navigation

* Implement deep-linking and playback controls for TTS media sessions

* Implement start index for TTS playback

* Improve TTS navigation, prefetching, and notification duration reporting

* Implement TTS mini playback bar for background reading

* Implement multi-window reader support for the desktop application

* Improve desktop modal window management and visibility syncing

* Implement localized string support for Desktop and shared UI

* Implement language selection and persistence for Desktop

* Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml

* Implement localized banner messages and UI strings using resource-backed SharedText

* Implement compact badge styling for small book covers

* Refactor PDF native interaction and improve HTML import memory safety

* fix language persistence

* Refactor reader overflow menus to use section-based logic

* Refactor PDF layout remapping and improve text box interaction

* Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets

* Centralize PDF annotation export mapping and improve metadata handling

* Add support for threaded comments in PDF highlight annotations

* Flatten highlight comments into a single thread for PDF export and allow author editing

* Integrate page slider into reader chrome and persist toggle state

* Handle fragments and queries in EPUB chapter paths

* Implement dynamic, theme-aware coloring for the reader slider

* Implement customizable app-wide font preference

* Implement one-hand zoom gestures in the PDF viewer

* Implement File Information dialog for PDF and EPUB readers

* Bump version to 1.0.49 (53)

* Refactor PDF reader logic into modular components

* Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens

* Add option to use PDF filenames as display names

* Fix preservation of PDF filename display preference in library projection
This commit is contained in:
Aryan 2026-05-20 22:14:01 +05:30 committed by GitHub
parent dc5196526f
commit 9510293ac3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
245 changed files with 37538 additions and 12460 deletions

View file

@ -123,6 +123,7 @@ class AndroidSettingsHubModelsTest {
uiState = ReaderScreenState(
isTabsEnabled = true,
useStrictFileFilter = true,
usePdfFileNameAsDisplayName = true,
isScreenCaptureProtectionEnabled = true
),
isOssBuild = false,
@ -134,6 +135,7 @@ class AndroidSettingsHubModelsTest {
assertTrue(toggles.getValue(SharedSettingsAction.TABS_TOGGLE).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.STRICT_FILE_FILTER).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.SCREEN_CAPTURE_PROTECTION).checked == true)
}
@ -153,6 +155,7 @@ class AndroidSettingsHubModelsTest {
assertTrue(SharedSettingsAction.LANGUAGE in extraActions)
assertTrue(SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR in extraActions)
assertTrue(SharedSettingsAction.STRICT_FILE_FILTER in extraActions)
assertTrue(SharedSettingsAction.PDF_FILENAME_DISPLAY_NAME in extraActions)
assertTrue(SharedSettingsAction.CLEAR_BOOK_CACHE in extraActions)
assertTrue(SharedSettingsAction.CLEAR_REFLOW_CACHE in extraActions)
assertTrue(SharedSettingsAction.TEST_PANEL_DETECTION in extraActions)

View file

@ -4,6 +4,7 @@ import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.AppAction as SharedAppAction
import com.aryan.reader.shared.AppFontPreference as SharedAppFontPreference
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
import org.junit.Assert.assertEquals
@ -78,6 +79,19 @@ class AndroidSharedStateBridgeTest {
assertEquals(AppThemeMode.DARK, result.appThemeMode)
}
@Test
fun `reduceAppAction applies shared app font preference back to Android fields`() {
val preference = SharedAppFontPreference.custom("font")
val result = AndroidSharedStateBridge.reduceAppAction(
current = ReaderScreenState(),
projectedState = ReaderScreenState(),
action = SharedAppAction.AppFontPreferenceChanged(preference)
)
assertEquals(preference, result.appFontPreference)
}
@Test
fun `setTabsEnabled disables shared tabs but preserves Android active reader session`() {
val result = AndroidSharedStateBridge.setTabsEnabled(

View file

@ -0,0 +1,143 @@
package com.aryan.reader
import java.io.File
import java.util.Date
import java.util.IllegalFormatException
import java.util.Locale
import javax.xml.parsers.DocumentBuilderFactory
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidStringFormatResourcesTest {
@Test
fun `localized formatted strings use valid formatter syntax`() {
val resDirectory = findResDirectory()
val baseStrings = readStringResources(File(resDirectory, "values/strings.xml"))
val formattedBaseStrings = baseStrings
.mapValues { (_, value) -> value.formatArguments() }
.filterValues { it.isNotEmpty() }
val failures = resDirectory
.listFiles()
.orEmpty()
.filter { it.isDirectory && it.name.startsWith("values") }
.map { File(it, "strings.xml") }
.filter { it.isFile }
.flatMap { stringsFile ->
val strings = readStringResources(stringsFile)
formattedBaseStrings.mapNotNull { (name, arguments) ->
val value = strings[name] ?: return@mapNotNull null
val sampleArguments = arguments.toSampleArguments()
try {
String.format(Locale.ROOT, value, *sampleArguments)
null
} catch (exception: IllegalFormatException) {
"${stringsFile.invariantSeparatorsPath}:$name -> ${exception.javaClass.simpleName}: ${exception.message}"
}
}
}
assertTrue(failures.joinToString(separator = "\n"), failures.isEmpty())
}
private fun findResDirectory(): File {
return listOf(
File("src/main/res"),
File("app/src/main/res")
).first { it.isDirectory }
}
private fun readStringResources(stringsFile: File): Map<String, String> {
val document = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(stringsFile)
val nodes = document.getElementsByTagName("string")
return buildMap {
for (index in 0 until nodes.length) {
val node = nodes.item(index)
val name = node.attributes
?.getNamedItem("name")
?.nodeValue
?: continue
put(name, node.textContent)
}
}
}
private fun String.formatArguments(): List<FormatArgument> {
val arguments = mutableListOf<FormatArgument>()
var nextImplicitIndex = 0
var previousIndex = -1
for (match in formatterPattern.findAll(this)) {
val conversion = (match.groups[4] ?: match.groups[5])?.value?.singleOrNull() ?: continue
val dateTimePrefix = match.groups[3]?.value
if (conversion == '%' || conversion == 'n') continue
val index = when {
match.groups[2] != null -> previousIndex
match.groups[1] != null -> match.groups[1]!!.value.toInt() - 1
else -> nextImplicitIndex++
}
if (index < 0) continue
previousIndex = index
val argument = FormatArgument(index, conversion.sampleKind(dateTimePrefix != null))
val existingIndex = arguments.indexOfFirst { it.index == index }
if (existingIndex >= 0) {
arguments[existingIndex] = arguments[existingIndex].merge(argument)
} else {
arguments += argument
}
}
return arguments
}
private fun Char.sampleKind(isDateTime: Boolean): SampleKind {
if (isDateTime) return SampleKind.DateTime
return when (lowercaseChar()) {
'd', 'o', 'x' -> SampleKind.Integer
'e', 'f', 'g', 'a' -> SampleKind.Decimal
'c' -> SampleKind.Character
'b' -> SampleKind.Boolean
'h', 's' -> SampleKind.Text
else -> SampleKind.Text
}
}
private fun List<FormatArgument>.toSampleArguments(): Array<Any> {
val maxIndex = maxOf { it.index }
val samples = Array<Any>(maxIndex + 1) { "sample" }
forEach { argument ->
samples[argument.index] = argument.kind.sample
}
return samples
}
private data class FormatArgument(
val index: Int,
val kind: SampleKind
) {
fun merge(other: FormatArgument): FormatArgument {
return if (kind == other.kind) this else copy(kind = SampleKind.Text)
}
}
private enum class SampleKind(val sample: Any) {
Text("sample"),
Integer(7),
Decimal(1.5),
Character('x'),
Boolean(true),
DateTime(Date(0L))
}
private companion object {
private val formatterPattern = Regex(
"%(?:([1-9]\\d*)\\$|(<))?[-#+ 0,(]*\\d*(?:\\.\\d+)?(?:(?:([tT])([a-zA-Z]))|([bBhHsScCdoxXeEfgGaA%n]))"
)
}
}

View file

@ -0,0 +1,34 @@
package com.aryan.reader
import com.aryan.reader.data.CustomFontEntity
import org.junit.Assert.assertNull
import org.junit.Test
class AppFontResolverTest {
@Test
fun `custom app font falls back to system when imported font is missing`() {
val resolved = AppFontPreference.custom("missing")
.toAndroidAppFontFamily(customFonts = emptyList())
assertNull(resolved)
}
@Test
fun `custom app font falls back to system when imported font file is gone`() {
val resolved = AppFontPreference.custom("font")
.toAndroidAppFontFamily(
customFonts = listOf(
CustomFontEntity(
id = "font",
displayName = "Missing",
fileName = "missing.ttf",
fileExtension = "ttf",
path = "build/test-tmp/AppFontResolverTest/missing-${System.nanoTime()}.ttf",
timestamp = 1L
)
)
)
assertNull(resolved)
}
}

View file

@ -6,6 +6,7 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.w3c.dom.Element
class AppLanguageOptionsTest {
@ -79,6 +80,25 @@ class AppLanguageOptionsTest {
assertEquals(readLocaleConfigTags(), supportedAppLanguageOptions.map { it.tag })
}
@Test
fun `android manifest enables AppCompat language persistence`() {
val manifest = readAndroidManifest()
val service = manifest.getElementsByTagName("service").asElements()
.singleOrNull {
it.androidAttribute("name") == "androidx.appcompat.app.AppLocalesMetadataHolderService"
}
assertTrue(service != null)
assertEquals("false", service!!.androidAttribute("enabled"))
assertEquals("false", service.androidAttribute("exported"))
val autoStoreLocales = service.getElementsByTagName("meta-data").asElements()
.singleOrNull { it.androidAttribute("name") == "autoStoreLocales" }
assertTrue(autoStoreLocales != null)
assertEquals("true", autoStoreLocales!!.androidAttribute("value"))
}
private fun readLocaleConfigTags(): List<String> {
val localeConfig = listOf(
File("src/main/res/xml/locales_config.xml"),
@ -101,4 +121,28 @@ class AppLanguageOptionsTest {
}
}
}
private fun readAndroidManifest(): org.w3c.dom.Document {
val manifest = listOf(
File("src/main/AndroidManifest.xml"),
File("app/src/main/AndroidManifest.xml")
).first { it.isFile }
return DocumentBuilderFactory.newInstance()
.apply { isNamespaceAware = true }
.newDocumentBuilder()
.parse(manifest)
}
private fun org.w3c.dom.NodeList.asElements(): List<Element> =
buildList {
for (index in 0 until length) {
val element = item(index) as? Element
if (element != null) add(element)
}
}
private fun Element.androidAttribute(name: String): String? =
attributes
?.getNamedItemNS("http://schemas.android.com/apk/res/android", name)
?.nodeValue
}

View file

@ -499,6 +499,70 @@ class LibraryStateProjectorTest {
assertEquals(listOf("folder_book"), folderShelf.directBooks.ids())
}
@Test
fun `project preserves app font preference when reusing cached library projection`() {
val book = recentFile("book")
val projector = LibraryStateProjector()
val input = LibraryProjectionInput(
state = ReaderScreenState(),
recentFilesFromDb = listOf(book),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
projector.project(input)
val result = projector.project(
input.copy(state = input.state.copy(appFontPreference = AppFontPreference.Monospace))
)
assertEquals(AppFontPreference.Monospace, result.appFontPreference)
}
@Test
fun `project preserves pdf filename display preference when reusing cached library projection`() {
val book = recentFile("book")
val projector = LibraryStateProjector()
val input = LibraryProjectionInput(
state = ReaderScreenState(),
recentFilesFromDb = listOf(book),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
projector.project(input)
val result = projector.project(
input.copy(state = input.state.copy(usePdfFileNameAsDisplayName = true))
)
assertTrue(result.usePdfFileNameAsDisplayName)
}
@Test
fun `cardTitle can prefer PDF filename over embedded metadata title`() {
val pdf = recentFile(
id = "pdf",
type = FileType.PDF,
displayName = "file-name.pdf",
title = "Metadata title"
)
val renamedPdf = pdf.copy(customName = "Manual name")
val epub = recentFile(
id = "epub",
type = FileType.EPUB,
displayName = "book.epub",
title = "EPUB title"
)
assertEquals("Metadata title", pdf.cardTitle())
assertEquals("file-name.pdf", pdf.cardTitle(usePdfFileNameAsDisplayName = true))
assertEquals("Manual name", renamedPdf.cardTitle(usePdfFileNameAsDisplayName = true))
assertEquals("EPUB title", epub.cardTitle(usePdfFileNameAsDisplayName = true))
}
private fun recentFile(
id: String,
uriString: String? = "content://$id",

View file

@ -145,6 +145,7 @@ class MainViewModelTest {
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
coEvery { anyConstructed<FontsRepository>().deleteFont(any()) } just Runs
viewModel = MainViewModel(mockApplication)
}
@ -210,6 +211,37 @@ class MainViewModelTest {
verify { mockEditor.putString("app_theme_mode", AppThemeMode.DARK.name) }
}
@Test
fun `setAppFontPreference persists app font preference`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val preference = AppFontPreference.custom("font")
viewModel.setAppFontPreference(preference)
val state = viewModel.uiState.first { it.appFontPreference == preference }
assertEquals(preference, state.appFontPreference)
verify { mockEditor.putString("app_font_kind", AppFontPreferenceKind.CUSTOM.name) }
verify { mockEditor.putString("app_font_custom_id", "font") }
}
@Test
fun `deleteFont resets matching app custom font preference`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setAppFontPreference(AppFontPreference.custom("font"))
viewModel.deleteFont("font")
advanceUntilIdle()
assertEquals(AppFontPreference.System, viewModel.uiState.value.appFontPreference)
coVerify { anyConstructed<FontsRepository>().deleteFont("font") }
verify { mockEditor.putString("app_font_kind", AppFontPreferenceKind.SYSTEM.name) }
verify { mockEditor.remove("app_font_custom_id") }
}
@Test
fun `setTabsEnabled persists to shared preferences`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
@ -286,20 +318,23 @@ class MainViewModelTest {
}
@Test
fun `strict file filter and external file behavior persist preferences`() = runTest {
fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setStrictFileFilter(true)
viewModel.setUsePdfFileNameAsDisplayName(true)
viewModel.setExternalFileBehavior("KEEP")
val state = viewModel.uiState.first {
it.useStrictFileFilter && it.externalFileBehavior == "KEEP"
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP"
}
assertTrue(state.useStrictFileFilter)
assertTrue(state.usePdfFileNameAsDisplayName)
assertEquals("KEEP", state.externalFileBehavior)
verify { mockEditor.putBoolean("use_strict_file_filter", true) }
verify { mockEditor.putBoolean("use_pdf_file_name_as_display_name", true) }
verify { mockEditor.putString("external_file_behavior", "KEEP") }
}

View file

@ -0,0 +1,18 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ReaderBrightnessSettingsTest {
@Test
fun `brightness settings default to system and clamp custom values`() {
val defaults = ReaderBrightnessSettings()
assertTrue(defaults.useSystemBrightness)
assertEquals(0.75f, defaults.safeCustomBrightness, 0.0001f)
assertEquals(0.05f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f)
assertEquals(1f, defaults.copy(customBrightness = 2f).safeCustomBrightness, 0.0001f)
}
}

View file

@ -0,0 +1,106 @@
package com.aryan.reader
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ReaderSliderChromeStateTest {
@Test
fun `toggle opens slider anchored to current page`() {
val state = readerSliderToggleState(
isCurrentlyToggledOn = false,
currentPage = 12
)
assertTrue(state.isToggledOn)
assertEquals(12, state.bookmarkPosition.startPage)
assertEquals(12f, state.bookmarkPosition.currentPage)
}
@Test
fun `toggle closes slider and resets bookmark anchor`() {
val state = readerSliderToggleState(
isCurrentlyToggledOn = true,
currentPage = 4
)
assertFalse(state.isToggledOn)
assertEquals(4, state.bookmarkPosition.startPage)
assertEquals(4f, state.bookmarkPosition.currentPage)
}
@Test
fun `slider only renders while toggled on and chrome is visible`() {
assertTrue(
shouldRenderReaderSlider(
isToggledOn = true,
isBottomChromeVisible = true,
isSearchActive = false
)
)
assertFalse(
shouldRenderReaderSlider(
isToggledOn = true,
isBottomChromeVisible = false,
isSearchActive = false
)
)
assertFalse(
shouldRenderReaderSlider(
isToggledOn = true,
isBottomChromeVisible = true,
isSearchActive = true
)
)
assertFalse(
shouldRenderReaderSlider(
isToggledOn = false,
isBottomChromeVisible = true,
isSearchActive = false
)
)
}
@Test
fun `bookmark position clamps invalid page to start`() {
val position = readerSliderBookmarkPosition(currentPage = -3)
assertEquals(0, position.startPage)
assertEquals(0f, position.currentPage)
}
@Test
fun `toggle preference key is scoped to book id`() {
assertEquals(
"reader_slider_toggle_book-123",
readerSliderTogglePreferenceKey("book-123")
)
}
@Test
fun `slider content color falls back on light page when theme text is low contrast`() {
val colors = readerSliderChromeColors(
pageBackground = Color.White,
pageText = Color.White,
themePrimary = Color(0xFF6750A4)
)
assertEquals(Color.Black, colors.contentColor)
}
@Test
fun `slider accent falls back when primary is low contrast against page`() {
val colors = readerSliderChromeColors(
pageBackground = Color.Black,
pageText = Color.White,
themePrimary = Color(0xFF050505)
)
assertEquals(Color.White, colors.activeTrackColor)
assertEquals(Color.White, colors.thumbColor)
assertEquals(Color.White, colors.bookmarkColor)
}
}

View file

@ -3,6 +3,7 @@ package com.aryan.reader
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.ReaderFeatureSurface
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf as SharedShelf
@ -10,6 +11,7 @@ import com.aryan.reader.shared.ShelfType as SharedShelfType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
class SharedModelMappersTest {
@ -112,6 +114,8 @@ class SharedModelMappersTest {
assertSame(folder, folder.toSharedSyncedFolder())
assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters())
assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder())
assertTrue(FileType.PPTX in PDF_VIEWER_FILE_TYPES)
assertEquals(ReaderFeatureSurface.PDF_VIEWER, FileType.PPTX.readerSurfaceOnAndroid())
assertFalse(FileType.UNKNOWN in ANDROID_READABLE_FILE_TYPES)
assertFalse(FileType.UNKNOWN in ANDROID_SYNCABLE_FILE_TYPES)
}

View file

@ -120,6 +120,27 @@ class SingleFileImporterTest {
assertTrue(File(book.extractionBasePath, "page_1.html").readText().contains("p { color: red; }"))
}
@Test
fun `html import chunks very long lines into bounded chapters`() = runTest {
val importer = SingleFileImporter(contextWithCache(temp.newFolder("html-long-line-cache")))
val longParagraph = "word ".repeat(260_000)
val html = "<html><body><p>$longParagraph</p></body></html>"
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream(html.toByteArray()),
type = FileType.HTML,
originalBookNameHint = "long.html",
bookId = "long-html-book"
)
assertTrue(book.chapters.size > 1)
book.chapters.forEach { chapter ->
val chapterFile = File(book.extractionBasePath, chapter.htmlFilePath)
assertTrue(chapterFile.isFile)
assertTrue(chapterFile.length() < 1_200_000L)
}
}
@Test
fun `csv txt wrapper imports as html table`() = runTest {
val importer = SingleFileImporter(contextWithCache(temp.newFolder("csv-cache")))

View file

@ -10,6 +10,7 @@ import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@ -178,6 +179,24 @@ class EpubReaderBridgeAndControlsTest {
verify { webView.evaluateJavascript("javascript:window.autoScroll.stop();", null) }
}
@Test
fun `web view hit test guard treats chromium null state as unknown tap`() {
val type = readWebViewHitTestTypeOrNull {
throw NullPointerException("chromium hit test result missing")
}
assertNull(type)
assertFalse(isWebViewAnchorHitTestType(type))
}
@Test
fun `web view hit test helper detects anchor result types`() {
assertTrue(isWebViewAnchorHitTestType(WebView.HitTestResult.SRC_ANCHOR_TYPE))
assertTrue(isWebViewAnchorHitTestType(WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE))
assertFalse(isWebViewAnchorHitTestType(null))
assertFalse(isWebViewAnchorHitTestType(WebView.HitTestResult.IMAGE_TYPE))
}
@Test
fun `initiateTtsPlayback chooses web extraction for vertical mode and callback for paginated mode`() {
val webView = mockk<WebView>(relaxed = true)
@ -197,11 +216,15 @@ class EpubReaderBridgeAndControlsTest {
assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" })
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category)
assertEquals("Top Bar", ReaderTool.BRIGHTNESS.category)
}
@Test
fun `reader toolbar reset defaults match first-run toolbar defaults`() {
assertEquals(setOf(ReaderTool.SCREEN_ORIENTATION.name), defaultReaderHiddenTools())
assertEquals(
setOf(ReaderTool.SCREEN_ORIENTATION.name, ReaderTool.BRIGHTNESS.name),
defaultReaderHiddenTools()
)
assertEquals(ReaderTool.entries.toList(), defaultReaderToolOrder())
assertEquals(
ReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(),
@ -218,9 +241,61 @@ class EpubReaderBridgeAndControlsTest {
ToolbarSection.HIDDEN,
defaultItems.single { it.tool == ReaderTool.SCREEN_ORIENTATION }.section
)
assertEquals(
ToolbarSection.HIDDEN,
defaultItems.single { it.tool == ReaderTool.BRIGHTNESS }.section
)
assertEquals(
ToolbarSection.BOTTOM,
defaultItems.single { it.tool == ReaderTool.SLIDER }.section
)
assertTrue(defaultItems.any { it.type == FlatItemType.MORE_TOOL && it.tool == ReaderTool.FILE_INFO })
}
@Test
fun `epub overflow sections end at auto scroll when tts submenu and file info are hidden`() {
val sections = epubOverflowMenuSections(
hiddenTools = setOf(
ReaderTool.TTS_SETTINGS.name,
ReaderTool.TTS_REPLACEMENTS.name
),
hasHiddenToolbarTools = false,
hasToggleReflow = false,
hasDeleteReflow = false,
hasFileInfo = false
)
assertEquals(EpubOverflowMenuSection.AUTO_SCROLL, sections.last())
assertTrue(EpubOverflowMenuSection.TTS_SETTINGS !in sections)
}
@Test
fun `epub overflow sections expose file info only when available and visible`() {
val visibleSections = epubOverflowMenuSections(
hiddenTools = emptySet(),
hasHiddenToolbarTools = false,
hasToggleReflow = false,
hasDeleteReflow = false,
hasFileInfo = true
)
val missingItemSections = epubOverflowMenuSections(
hiddenTools = emptySet(),
hasHiddenToolbarTools = false,
hasToggleReflow = false,
hasDeleteReflow = false,
hasFileInfo = false
)
val hiddenSections = epubOverflowMenuSections(
hiddenTools = setOf(ReaderTool.FILE_INFO.name),
hasHiddenToolbarTools = false,
hasToggleReflow = false,
hasDeleteReflow = false,
hasFileInfo = true
)
assertTrue(EpubOverflowMenuSection.FILE_INFO in visibleSections)
assertEquals(EpubOverflowMenuSection.FILE_INFO, visibleSections.last())
assertFalse(EpubOverflowMenuSection.FILE_INFO in missingItemSections)
assertFalse(EpubOverflowMenuSection.FILE_INFO in hiddenSections)
}
}

View file

@ -4,6 +4,7 @@ import android.content.Context
import com.aryan.reader.R
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.epub.hasReadableExtractedContent
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.LocatorConverter
import io.mockk.coEvery
@ -47,9 +48,38 @@ class EpubReaderContentTest {
assertFalse(result.chunks.joinToString().contains("<script>"))
assertTrue(result.chunks[0].contains("Paragraph 20"))
assertTrue(result.chunks[1].contains("Paragraph 21"))
assertEquals(listOf(0, 20), result.chunkElementStartIndices)
assertEquals(listOf(20, 1), result.chunkElementCounts)
assertEquals(0, result.startChunkIndex)
}
@Test
fun `loadChapterContent records element starts independently from whitespace text nodes`() = runTest {
val root = temp.newFolder("content-whitespace")
val body = (1..25).joinToString(separator = "\n", prefix = "\n", postfix = "\n") { index ->
"<p>Paragraph $index</p>"
}
writeChapter(root, "chapter.xhtml", "<html><body>$body</body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml")))
val result = loadChapterContent(
context = contextWithStrings(),
epubBook = book,
chapterIndex = 0,
chunkTargetOverride = null,
isInitialCfiLoad = false,
cfiToLoad = null,
locatorConverter = mockk()
)
assertEquals(listOf(0, 10, 20), result.chunkElementStartIndices)
assertEquals(listOf(10, 10, 5), result.chunkElementCounts)
assertEquals(
"data-chunk-index='1' data-element-start-index='10' data-element-count='10'",
readerChunkContainerAttributes(1, result.chunkElementStartIndices, result.chunkElementCounts)
)
}
@Test
fun `loadChapterContent clamps explicit chunk override into available chunk range`() = runTest {
val root = temp.newFolder("override")
@ -64,6 +94,19 @@ class EpubReaderContentTest {
assertEquals(0, low.startChunkIndex)
}
@Test
fun `loadChapterContent ignores fragment and query in chapter file path`() = runTest {
val root = temp.newFolder("path-fragment")
writeChapter(root, "chapter.xhtml", "<html><body><p>Found chapter</p></body></html>")
val book = epubBook(root, listOf(chapter("chapter.xhtml#anchor?ignored")))
val result = loadChapterContent(contextWithStrings(), book, 0, null, false, null, mockk())
assertTrue(book.hasReadableExtractedContent())
assertTrue(result.isSuccess)
assertEquals(listOf("<p>Found chapter</p>"), result.chunks)
}
@Test
fun `loadChapterContent calculates initial chunk from cfi locator block index`() = runTest {
val root = temp.newFolder("cfi")

View file

@ -0,0 +1,101 @@
package com.aryan.reader.epubreader
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class EpubReaderImagesTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `readerImageReferencesForDrawer extracts images in reading order with chunk targets`() {
val root = temp.newFolder("book")
val imageFile = File(root, "OPS/Images/cover.png").apply {
parentFile?.mkdirs()
writeBytes(byteArrayOf(1, 2, 3))
}
val firstImage = """<p>Intro <img id="cover" src="../Images/cover.png" alt="Cover art" width="640" height="480"/></p>"""
val filler = (1..20).joinToString(separator = "") { "<p>Paragraph $it</p>" }
writeFile(
root,
"OPS/Text/chapter.xhtml",
"<html><body>$firstImage$filler<img src=\"../Images/cover.png\" alt=\"Second cover\"/></body></html>"
)
val book = epubBook(
root,
listOf(chapter(path = "OPS/Text/chapter.xhtml", title = "Chapter One"))
)
val images = book.readerImageReferencesForDrawer()
assertEquals(2, images.size)
assertEquals("Cover art", images[0].displayTitle)
assertEquals(imageFile.canonicalPath, images[0].sourcePath)
assertEquals(0, images[0].ordinalInChapter)
assertEquals(0, images[0].chunkIndex)
assertEquals(640, images[0].intrinsicWidth)
assertEquals(480, images[0].intrinsicHeight)
assertEquals(1, images[1].ordinalInChapter)
assertEquals(1, images[1].chunkIndex)
assertEquals("Chapter One", images[1].chapterTitle)
}
@Test
fun `readerImageReference supports data uri download bytes and safe names`() {
val root = temp.newFolder("data-uri")
val book = epubBook(
root,
listOf(
chapter(
path = "chapter.xhtml",
title = "Inline",
htmlContent = """<html><body><img src="data:image/png;base64,SGk=" alt="bad/name"/></body></html>"""
)
)
)
val image = book.readerImageReferencesForDrawer().single()
assertEquals("bad_name.png", image.suggestedDownloadFileName())
assertEquals("image/png", image.mimeType())
assertArrayEquals("Hi".toByteArray(), image.readDownloadBytes())
}
private fun writeFile(root: File, relativePath: String, content: String) {
val file = File(root, relativePath)
file.parentFile?.mkdirs()
file.writeText(content)
}
private fun epubBook(root: File, chapters: List<EpubChapter>): EpubBook =
EpubBook(
fileName = "book.epub",
title = "Book",
author = "Author",
language = "en",
coverImage = null,
chapters = chapters,
extractionBasePath = root.absolutePath
)
private fun chapter(
path: String,
title: String,
htmlContent: String = ""
): EpubChapter =
EpubChapter(
chapterId = path,
absPath = path,
title = title,
htmlFilePath = path,
plainTextContent = "",
htmlContent = htmlContent
)
}

View file

@ -0,0 +1,50 @@
package com.aryan.reader.epubreader
import com.aryan.reader.paginatedreader.TtsChunk
import org.junit.Assert.assertEquals
import org.junit.Test
class EpubTtsChunkMatchingTest {
@Test
fun `chunk start matching tolerates child cfi path and whitespace text differences`() {
val chunks = listOf(
TtsChunk(
text = "The first paragraph begins here.",
sourceCfi = "/4/22/2",
startOffsetInSource = 0
),
TtsChunk(
text = "The second paragraph begins here.",
sourceCfi = "/4/24/2",
startOffsetInSource = 0
)
)
val extracted = TtsChunk(
text = "The second paragraph begins here.",
sourceCfi = "/4/24",
startOffsetInSource = 0
)
assertEquals(1, findTtsChunkStartIndex(chunks, extracted))
}
@Test
fun `resume matching falls back to current chunk index before leaving chapter`() {
val chunks = listOf(
TtsChunk("One", "/4/2", 0),
TtsChunk("Two", "/4/4", 0),
TtsChunk("Three", "/4/6", 0)
)
assertEquals(
1,
findTtsChunkResumeIndex(
chunks = chunks,
sourceCfi = "/mismatched",
startOffsetInSource = 0,
currentText = "unknown",
currentChunkIndexFallback = 1
)
)
}
}

View file

@ -0,0 +1,124 @@
package com.aryan.reader.pdf
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import org.junit.Assert.assertEquals
import org.junit.Test
class PdfOneHandZoomTest {
@Test
fun `downward one hand drag zooms in`() {
val scale = pdfOneHandZoomScale(
startScale = 1f,
totalDragY = 240f,
dragDistanceForDoublePx = 240f,
minScale = 1f,
maxScale = 4f
)
assertEquals(2f, scale, 0.0001f)
}
@Test
fun `upward one hand drag zooms out`() {
val scale = pdfOneHandZoomScale(
startScale = 2f,
totalDragY = -240f,
dragDistanceForDoublePx = 240f,
minScale = 1f,
maxScale = 4f
)
assertEquals(1f, scale, 0.0001f)
}
@Test
fun `one hand zoom scale clamps to caller bounds`() {
val zoomedIn = pdfOneHandZoomScale(
startScale = 3.5f,
totalDragY = 240f,
dragDistanceForDoublePx = 240f,
minScale = 1f,
maxScale = 4f
)
val zoomedOut = pdfOneHandZoomScale(
startScale = 1.2f,
totalDragY = -480f,
dragDistanceForDoublePx = 240f,
minScale = 1f,
maxScale = 4f
)
assertEquals(4f, zoomedIn, 0.0001f)
assertEquals(1f, zoomedOut, 0.0001f)
}
@Test
fun `centered camera zoom preserves pivot content point`() {
val viewport = Size(1000f, 1000f)
val content = Size(1000f, 1000f)
val pivot = Offset(300f, 400f)
val nextOffset = centeredPdfCameraOffsetForScaleChange(
previousScale = 1f,
nextScale = 2f,
previousOffset = Offset.Zero,
pivot = pivot,
viewportSize = viewport,
contentSize = content
)
val before = contentPointForCenteredCamera(
screenPoint = pivot,
scale = 1f,
offset = Offset.Zero,
viewportSize = viewport
)
val after = contentPointForCenteredCamera(
screenPoint = pivot,
scale = 2f,
offset = nextOffset,
viewportSize = viewport
)
assertEquals(before.x, after.x, 0.0001f)
assertEquals(before.y, after.y, 0.0001f)
}
@Test
fun `center pivot zoom keeps camera centered`() {
val viewport = Size(1000f, 1000f)
val offset = centeredPdfCameraOffsetForScaleChange(
previousScale = 1f,
nextScale = 2f,
previousOffset = Offset.Zero,
pivot = Offset(500f, 500f),
viewportSize = viewport,
contentSize = viewport
)
assertEquals(0f, offset.x, 0.0001f)
assertEquals(0f, offset.y, 0.0001f)
}
@Test
fun `held second tap without movement is not a zoom action`() {
val action = classifyPdfSecondTapZoomAction(
pressDurationMillis = PDF_ONE_HAND_ZOOM_HOLD_TIMEOUT_MS + 40L,
totalDragY = 1f,
movementSlopPx = 8f
)
assertEquals(PdfSecondTapZoomAction.HELD_NO_MOVEMENT, action)
}
private fun contentPointForCenteredCamera(
screenPoint: Offset,
scale: Float,
offset: Offset,
viewportSize: Size
): Offset {
val center = Offset(viewportSize.width / 2f, viewportSize.height / 2f)
return ((screenPoint - offset - center) / scale) + center
}
}

View file

@ -3,6 +3,7 @@ package com.aryan.reader.pdf
import android.content.Context
import android.graphics.RectF
import android.graphics.Rect
import android.net.Uri
import androidx.compose.ui.graphics.Color
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfAnnotationRepository
@ -21,6 +22,7 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
@RunWith(RobolectricTestRunner::class)
class PdfReaderCoreLogicTest {
@ -109,6 +111,18 @@ class PdfReaderCoreLogicTest {
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
}
@Test
fun `getFastFileId uses stable file name and length for file uris`() {
val file = File("build/test-tmp/pdf-reader/fast-id-${System.nanoTime()}.pdf").apply {
parentFile?.mkdirs()
writeText("pdf")
}
val id = getFastFileId(RuntimeEnvironment.getApplication(), Uri.fromFile(file))
assertEquals("${file.name}_${file.length()}", id)
}
@Test
fun `pdf export choice is hidden when loaded sidecars have no annotations`() {
assertFalse(
@ -183,6 +197,118 @@ class PdfReaderCoreLogicTest {
)
}
@Test
fun `embedded annotation grouping links replies by annotation id`() {
val root = embeddedAnnotation(index = 0, name = "root", contents = "Parent")
val reply = embeddedAnnotation(index = 1, name = "reply", inReplyTo = "root", contents = "Child")
val grouped = groupEmbeddedAnnotationsForDisplay(listOf(root, reply))
assertEquals(listOf(root), grouped)
assertEquals(listOf(reply), grouped.single().replies)
}
@Test
fun `embedded annotation grouping keeps geometric replies that provide visible content`() {
val blankRoot = embeddedAnnotation(index = 0, rect = RectF(0f, 0f, 20f, 20f), contents = "")
val nearbyReply = embeddedAnnotation(index = 1, rect = RectF(25f, 0f, 40f, 20f), contents = "Visible")
val emptyStandalone = embeddedAnnotation(index = 2, rect = RectF(200f, 0f, 220f, 20f), contents = "")
val grouped = groupEmbeddedAnnotationsForDisplay(listOf(blankRoot, nearbyReply, emptyStandalone))
assertEquals(listOf(blankRoot), grouped)
assertEquals(listOf(nearbyReply), grouped.single().replies)
}
@Test
fun `layout remap keeps annotations on their virtual pages when inserting a blank page`() {
val existingBlank = VirtualPage.BlankPage("existing-blank", 612, 792, wasManuallyAdded = true)
val insertedBlank = VirtualPage.BlankPage("inserted-blank", 612, 792, wasManuallyAdded = true)
val currentLayout = listOf(
VirtualPage.PdfPage(0),
existingBlank,
VirtualPage.PdfPage(1),
VirtualPage.PdfPage(5)
)
val updatedLayout = listOf(
VirtualPage.PdfPage(0),
insertedBlank,
existingBlank,
VirtualPage.PdfPage(1),
VirtualPage.PdfPage(5)
)
val annotations = mapOf(
0 to listOf(testInkAnnotation(id = "pdf-0", pageIndex = 0)),
1 to listOf(testInkAnnotation(id = "existing-blank", pageIndex = 99)),
2 to listOf(testInkAnnotation(id = "pdf-1", pageIndex = 2)),
3 to listOf(testInkAnnotation(id = "pdf-5", pageIndex = 3))
)
val remapped = remapPdfAnnotationsForLayoutChange(currentLayout, updatedLayout, annotations)
assertNull(remapped[1])
assertEquals(setOf(0, 2, 3, 4), remapped.keys)
assertEquals("pdf-0", remapped.getValue(0).single().id)
assertEquals(0, remapped.getValue(0).single().pageIndex)
assertEquals("existing-blank", remapped.getValue(2).single().id)
assertEquals(2, remapped.getValue(2).single().pageIndex)
assertEquals("pdf-1", remapped.getValue(3).single().id)
assertEquals(3, remapped.getValue(3).single().pageIndex)
assertEquals("pdf-5", remapped.getValue(4).single().id)
assertEquals(4, remapped.getValue(4).single().pageIndex)
}
@Test
fun `layout remap drops annotations from a removed blank page and keeps later pdf annotations`() {
val removedBlank = VirtualPage.BlankPage("removed-blank", 612, 792, wasManuallyAdded = true)
val currentLayout = listOf(VirtualPage.PdfPage(0), removedBlank, VirtualPage.PdfPage(1))
val updatedLayout = listOf(VirtualPage.PdfPage(0), VirtualPage.PdfPage(1))
val annotations = mapOf(
1 to listOf(testInkAnnotation(id = "blank-note", pageIndex = 1)),
2 to listOf(testInkAnnotation(id = "pdf-1", pageIndex = 2))
)
val remapped = remapPdfAnnotationsForLayoutChange(currentLayout, updatedLayout, annotations)
assertNull(remapped[0])
assertEquals(setOf(1), remapped.keys)
assertEquals("pdf-1", remapped.getValue(1).single().id)
assertEquals(1, remapped.getValue(1).single().pageIndex)
}
@Test
fun `text box chrome layout keeps drag pill inside hit bounds without moving text body`() {
val bounds = androidx.compose.ui.geometry.Rect(100f, 200f, 180f, 260f)
val bottomHandle = calculateTextBoxChromeLayout(
textBoundsPx = bounds,
isSelected = true,
isHandleAtTop = false,
handleSizePx = 10f,
dragPillWidthPx = 72f,
dragPillHeightPx = 48f,
dragPillGapPx = 8f
)
val topHandle = calculateTextBoxChromeLayout(
textBoundsPx = bounds,
isSelected = true,
isHandleAtTop = true,
handleSizePx = 10f,
dragPillWidthPx = 72f,
dragPillHeightPx = 48f,
dragPillGapPx = 8f
)
listOf(bottomHandle, topHandle).forEach { layout ->
assertEquals(bounds.left, layout.outerTranslationX + layout.contentOffsetX + 5f, 0.0001f)
assertEquals(bounds.top, layout.outerTranslationY + layout.contentOffsetY + 5f, 0.0001f)
assertTrue(layout.dragPillLeftPx >= 0f)
assertTrue(layout.dragPillLeftPx + 72f <= layout.containerWidthPx)
assertTrue(layout.dragPillTopPx >= 0f)
assertTrue(layout.dragPillTopPx + 48f <= layout.containerHeightPx)
}
assertEquals(0f, topHandle.dragPillTopPx, 0.0001f)
}
@Test
fun `bubble prefetch only includes current page and nearby pages`() {
assertEquals(listOf(10, 11, 9), buildPdfBubblePrefetchOrder(currentPage = 10, totalPages = 100))
@ -195,6 +321,68 @@ class PdfReaderCoreLogicTest {
assertEquals(emptyList<Int>(), buildPdfBubblePrefetchOrder(currentPage = 0, totalPages = 0))
}
@Test
fun `bubble zoom factor fits bubble inside viewport target and clamps extremes`() {
assertEquals(
2f,
computeDynamicBubbleZoomFactor(
bubbleBounds = RectF(0f, 0f, 300f, 80f),
viewportWidth = 1_000f,
viewportHeight = 1_000f
),
0.0001f
)
assertEquals(
1.5f,
computeDynamicBubbleZoomFactor(
bubbleBounds = RectF(0f, 0f, 0f, 80f),
viewportWidth = 1_000f,
viewportHeight = 1_000f
),
0.0001f
)
assertEquals(
4.25f,
computeDynamicBubbleZoomFactor(
bubbleBounds = RectF(0f, 0f, 10f, 10f),
viewportWidth = 1_000f,
viewportHeight = 1_000f
),
0.0001f
)
}
@Test
fun `safe pdf bitmap render scale keeps small renders and limits large renders`() {
assertEquals(
2f,
safePdfBitmapRenderScale(
contentWidth = 100f,
contentHeight = 100f,
requestedScale = 2f
),
0.0001f
)
assertEquals(
1f,
safePdfBitmapRenderScale(
contentWidth = 0f,
contentHeight = 100f,
requestedScale = 2f
),
0.0001f
)
val limitedScale = safePdfBitmapRenderScale(
contentWidth = 10_000f,
contentHeight = 10_000f,
requestedScale = 2f
)
assertTrue(limitedScale < 2f)
assertTrue(limitedScale >= 0.01f)
}
@Test
fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() {
assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true))
@ -203,6 +391,50 @@ class PdfReaderCoreLogicTest {
assertEquals(false, canUsePdfSidecarsForBook(null, "book-a", areSidecarsLoaded = true))
}
@Test
fun `canManagePdfVirtualPages waits for the active page layout to load`() {
assertTrue(
canManagePdfVirtualPages(
isDocumentReady = true,
currentBookId = "book-a",
loadedPageLayoutBookId = "book-a",
virtualPageCount = 3
)
)
assertFalse(
canManagePdfVirtualPages(
isDocumentReady = true,
currentBookId = "book-a",
loadedPageLayoutBookId = null,
virtualPageCount = 3
)
)
assertFalse(
canManagePdfVirtualPages(
isDocumentReady = true,
currentBookId = "book-a",
loadedPageLayoutBookId = "book-b",
virtualPageCount = 3
)
)
assertFalse(
canManagePdfVirtualPages(
isDocumentReady = false,
currentBookId = "book-a",
loadedPageLayoutBookId = "book-a",
virtualPageCount = 3
)
)
assertFalse(
canManagePdfVirtualPages(
isDocumentReady = true,
currentBookId = "book-a",
loadedPageLayoutBookId = "book-a",
virtualPageCount = 0
)
)
}
@Test
fun `saveAnnotations deletes stored annotations when saving empty map`() = runTest {
val context: Context = RuntimeEnvironment.getApplication()
@ -378,6 +610,36 @@ class PdfReaderCoreLogicTest {
return OcrResult(text = line.text, textBlocks = listOf(block))
}
private fun embeddedAnnotation(
index: Int,
rect: RectF = RectF(0f, 0f, 20f, 20f),
name: String? = null,
inReplyTo: String? = null,
contents: String? = null
): EmbeddedAnnotation {
return EmbeddedAnnotation(
index = index,
subtype = 0,
rect = rect,
contents = contents,
author = null,
name = name,
inReplyTo = inReplyTo
)
}
private fun testInkAnnotation(id: String, pageIndex: Int): PdfAnnotation {
return PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = pageIndex,
points = listOf(PdfPoint(0.1f, 0.2f), PdfPoint(0.2f, 0.3f)),
color = Color.Black,
strokeWidth = 0.01f,
id = id
)
}
private fun assertRectFEquals(expected: RectF, actual: RectF) {
assertEquals(expected.left, actual.left, 0.0001f)
assertEquals(expected.top, actual.top, 0.0001f)

View file

@ -6,6 +6,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.epubreader.SystemUiMode
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
@ -26,13 +27,19 @@ class PdfReaderPreferencesTest {
val context = contextWithPrefs(prefs)
val order = loadPdfToolOrder(context)
val expectedTools = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable)
assertEquals(listOf(PdfReaderTool.SEARCH, PdfReaderTool.TOC), order.take(2))
assertEquals(PdfReaderTool.entries.size, order.size)
assertEquals(PdfReaderTool.entries.toSet(), order.toSet())
assertEquals(expectedTools.size, order.size)
assertEquals(expectedTools.toSet(), order.toSet())
assertEquals(setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.TOC.name), loadPdfBottomTools(context))
assertEquals(
setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SCREEN_ORIENTATION.name, PdfReaderTool.HIGHLIGHT_ALL.name),
setOf(
PdfReaderTool.PRINT.name,
PdfReaderTool.SCREEN_ORIENTATION.name,
PdfReaderTool.HIGHLIGHT_ALL.name,
PdfReaderTool.BRIGHTNESS.name
),
loadPdfHiddenTools(context)
)
}
@ -49,6 +56,7 @@ class PdfReaderPreferencesTest {
assertEquals(setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name), loadPdfHiddenTools(context))
assertFalse(PdfReaderTool.SCREEN_ORIENTATION.name in loadPdfHiddenTools(context))
assertFalse(PdfReaderTool.HIGHLIGHT_ALL.name in loadPdfHiddenTools(context))
assertFalse(PdfReaderTool.BRIGHTNESS.name in loadPdfHiddenTools(context))
assertEquals(setOf(PdfReaderTool.SEARCH.name), loadPdfBottomTools(context))
assertEquals(listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH), loadPdfToolOrder(context).take(2))
}
@ -61,7 +69,8 @@ class PdfReaderPreferencesTest {
DOCK_OFFSET_X_KEY to 12.5f,
DOCK_OFFSET_Y_KEY to -7.25f,
OCR_LANGUAGE_KEY to "UNKNOWN",
PDF_SYSTEM_UI_MODE_KEY to Int.MIN_VALUE
PDF_SYSTEM_UI_MODE_KEY to Int.MIN_VALUE,
PDF_PAGE_SPREAD_MODE_KEY to "SIDEWAYS"
)
val context = contextWithPrefs(prefs)
@ -69,6 +78,7 @@ class PdfReaderPreferencesTest {
assertEquals(DockLocation.BOTTOM to Offset(12.5f, -7.25f), loadDockState(context))
assertEquals(OcrLanguage.LATIN, loadOcrLanguage(context))
assertEquals(SystemUiMode.SYNC, loadPdfSystemUiMode(context))
assertEquals(ReaderPageSpreadMode.SINGLE, loadPdfPageSpreadMode(context))
assertFalse(hasUserSelectedOcrLanguage(context))
}
@ -81,12 +91,16 @@ class PdfReaderPreferencesTest {
saveDockState(context, DockLocation.FLOATING, Offset(3f, 4f))
saveOcrLanguage(context, OcrLanguage.JAPANESE)
savePdfSystemUiMode(context, SystemUiMode.HIDDEN)
savePdfPageSpreadMode(context, ReaderPageSpreadMode.TWO_PAGE)
savePdfFirstPageStandaloneInSpread(context, true)
assertEquals(DisplayMode.PAGINATION, loadDisplayMode(context))
assertEquals(DockLocation.FLOATING to Offset(3f, 4f), loadDockState(context))
assertEquals(OcrLanguage.JAPANESE, loadOcrLanguage(context))
assertTrue(hasUserSelectedOcrLanguage(context))
assertEquals(SystemUiMode.HIDDEN, loadPdfSystemUiMode(context))
assertEquals(ReaderPageSpreadMode.TWO_PAGE, loadPdfPageSpreadMode(context))
assertTrue(loadPdfFirstPageStandaloneInSpread(context))
}
@Test
@ -94,8 +108,11 @@ class PdfReaderPreferencesTest {
val prefs = InMemorySharedPreferences()
val context = contextWithPrefs(prefs)
assertTrue(loadPdfTopTabStripVisible(context))
savePdfThemeId(context, "sepia")
saveKeepScreenOn(context, true)
savePdfTopTabStripVisible(context, false)
saveUseOnlineDict(context, false)
saveExternalDictPackage(context, "com.example.dict")
saveExternalTranslatePackage(context, "com.example.translate")
@ -108,6 +125,7 @@ class PdfReaderPreferencesTest {
assertEquals("sepia", loadPdfThemeId(context))
assertTrue(loadKeepScreenOn(context))
assertFalse(loadPdfTopTabStripVisible(context))
assertFalse(loadUseOnlineDict(context))
assertEquals("com.example.dict", loadExternalDictPackage(context))
assertEquals("com.example.translate", loadExternalTranslatePackage(context))

View file

@ -136,7 +136,7 @@ class PdfReaderRepositoryTest {
repository.saveLayout("folder/book.pdf", pages)
assertEquals(pages, repository.loadLayout("folder/book.pdf", totalPdfPages = 10))
assertNotNull(repository.getLayoutOrNull("folder/book.pdf"))
assertEquals(pages, repository.getLayoutOrNull("folder/book.pdf"))
assertTrue(File(context.filesDir, "page_layouts/layout_folder_book.pdf.json").exists())
}

View file

@ -10,6 +10,7 @@ import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.sp
import com.aryan.reader.pdf.data.VirtualPage
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
@ -180,6 +181,96 @@ class PdfReaderRichTextTest {
assertTrue("${PAGE_BREAK_CHAR}\nVisible".hasRenderableRichText())
}
@Test
fun `blank page insertion uses one page break when the rich text boundary is already explicit`() {
val text = "Page 1${PAGE_BREAK_CHAR}Page 2"
val insertionIndex = "Page 1${PAGE_BREAK_CHAR}".length
assertEquals(1, androidRichTextBlankInsertBreakCount(text, insertionIndex))
assertEquals(1, androidRichTextBlankInsertBreakCount("Page 1", "Page 1".length))
assertEquals(1, androidRichTextBlankInsertBreakCount("Page 1", 0))
}
@Test
fun `blank page insertion uses two page breaks only for measured text boundaries with following content`() {
val text = "Page 1Page 2"
val insertionIndex = "Page 1".length
assertEquals(2, androidRichTextBlankInsertBreakCount(text, insertionIndex))
assertEquals(
insertionIndex,
androidRichTextInsertionIndexForPage(
insertPageIndex = 1,
pageLayouts = listOf(
PageTextLayout(
pageIndex = 0,
visibleText = AnnotatedString("Page 1"),
globalStartIndex = 0,
globalEndIndex = insertionIndex,
pageHeightPx = 1_000f
),
PageTextLayout(
pageIndex = 1,
visibleText = AnnotatedString("Page 2"),
globalStartIndex = insertionIndex,
globalEndIndex = text.length,
pageHeightPx = 1_000f
)
),
textLength = text.length
)
)
}
@Test
fun `rich text remap keeps later text on same pdf page when inserting a blank page`() {
val currentLayout = listOf(VirtualPage.PdfPage(0), VirtualPage.PdfPage(1))
val updatedLayout = listOf(
VirtualPage.PdfPage(0),
VirtualPage.BlankPage("blank", 612, 792, wasManuallyAdded = true),
VirtualPage.PdfPage(1)
)
val pageLayouts = listOf(
PageTextLayout(
pageIndex = 0,
visibleText = AnnotatedString("Page 1$PAGE_BREAK_CHAR"),
globalStartIndex = 0,
globalEndIndex = 7,
pageHeightPx = 1_000f
),
PageTextLayout(
pageIndex = 1,
visibleText = AnnotatedString("Page 2"),
globalStartIndex = 7,
globalEndIndex = 13,
pageHeightPx = 1_000f
)
)
val remapped = remapAndroidRichTextForLayoutChange(currentLayout, updatedLayout, pageLayouts)
assertEquals("Page 1${PAGE_BREAK_CHAR}${PAGE_BREAK_CHAR}Page 2", remapped.text)
}
@Test
fun `rich text remap drops deleted blank page and shifts later text back`() {
val currentLayout = listOf(
VirtualPage.PdfPage(0),
VirtualPage.BlankPage("blank", 612, 792, wasManuallyAdded = true),
VirtualPage.PdfPage(1)
)
val updatedLayout = listOf(VirtualPage.PdfPage(0), VirtualPage.PdfPage(1))
val pageLayouts = listOf(
PageTextLayout(0, AnnotatedString("A$PAGE_BREAK_CHAR"), 0, 2, 1_000f),
PageTextLayout(1, AnnotatedString("$PAGE_BREAK_CHAR"), 2, 3, 1_000f),
PageTextLayout(2, AnnotatedString("B"), 3, 4, 1_000f)
)
val remapped = remapAndroidRichTextForLayoutChange(currentLayout, updatedLayout, pageLayouts)
assertEquals("A${PAGE_BREAK_CHAR}B", remapped.text)
}
@Test
fun `PdfRichTextRepository saves and loads rich document with sanitized book id`() = runTest {
val context = contextWithFilesDir(tempRoot("rich-save-load"))

View file

@ -9,6 +9,7 @@ import com.aryan.reader.pdf.data.HighlightSerializer
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.TextBoxSerializer
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
@ -168,7 +169,23 @@ class PdfReaderSerializerTest {
color = PdfHighlightColor.BLUE,
text = "Selected text",
range = 7 to 20,
note = "Important"
note = "Important",
comments = listOf(
SharedPdfAnnotationComment(
id = "comment-1",
author = "Ada",
contents = "First comment",
createdAt = 100L,
modifiedAt = 120L
),
SharedPdfAnnotationComment(
id = "comment-2",
parentId = "comment-1",
author = "Bea",
contents = "Nested reply",
createdAt = 130L
)
)
)
)
@ -180,6 +197,10 @@ class PdfReaderSerializerTest {
assertEquals("Selected text", decoded.text)
assertEquals(7 to 20, decoded.range)
assertEquals("Important", decoded.note)
assertEquals(listOf("comment-1", "comment-2"), decoded.comments.map { it.id })
assertEquals("comment-1", decoded.comments[1].parentId)
assertEquals("Ada", decoded.comments[0].author)
assertEquals(120L, decoded.comments[0].modifiedAt)
assertEquals(1, decoded.bounds.size)
assertRectFEquals(RectF(0f, 0f, 1f, 1f), decoded.bounds.single())

View file

@ -2,6 +2,8 @@ package com.aryan.reader.pdf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.BuildConfig
import com.aryan.reader.FileType
import com.aryan.reader.pdf.data.AnnotationSettingsRepository
import com.aryan.reader.pdf.data.AnnotationToolSettings
import com.aryan.reader.pdf.data.TextStyleConfig
@ -16,6 +18,7 @@ import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
@ -164,12 +167,18 @@ class PdfReaderSettingsAndSharedModelsTest {
@Test
fun `pdf toolbar reset defaults match first-run toolbar defaults`() {
assertEquals(
setOf(PdfReaderTool.SCREEN_ORIENTATION.name, PdfReaderTool.HIGHLIGHT_ALL.name),
setOf(
PdfReaderTool.SCREEN_ORIENTATION.name,
PdfReaderTool.HIGHLIGHT_ALL.name,
PdfReaderTool.BRIGHTNESS.name
),
defaultPdfHiddenTools()
)
assertEquals(PdfReaderTool.entries.toList(), defaultPdfToolOrder())
val expectedToolOrder = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable)
assertEquals(expectedToolOrder, defaultPdfToolOrder())
assertEquals(
PdfReaderTool.entries.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(),
expectedToolOrder.filter { it.category == "Bottom Bar" }.map { it.name }.toSet(),
defaultPdfBottomTools()
)
@ -187,9 +196,107 @@ class PdfReaderSettingsAndSharedModelsTest {
PdfToolbarSection.HIDDEN,
defaultItems.single { it.tool == PdfReaderTool.HIGHLIGHT_ALL }.section
)
assertEquals(
PdfToolbarSection.HIDDEN,
defaultItems.single { it.tool == PdfReaderTool.BRIGHTNESS }.section
)
assertEquals(
PdfToolbarSection.BOTTOM,
defaultItems.single { it.tool == PdfReaderTool.SLIDER }.section
)
val expectedMoreTools = buildSet {
addAll(
setOf(
PdfReaderTool.FILE_INFO,
PdfReaderTool.VISUAL_OPTIONS,
PdfReaderTool.TAP_TO_TURN,
PdfReaderTool.READING_MODE,
PdfReaderTool.KEEP_SCREEN_ON,
PdfReaderTool.AUTO_SCROLL,
PdfReaderTool.TTS_SETTINGS,
PdfReaderTool.TTS_REPLACEMENTS,
PdfReaderTool.BOOKMARK,
PdfReaderTool.PAGE_MANAGEMENT,
PdfReaderTool.REFLOW,
PdfReaderTool.SHARE,
PdfReaderTool.SAVE_COPY,
PdfReaderTool.PRINT
)
)
if (BuildConfig.IS_PRO) add(PdfReaderTool.OCR_LANGUAGE)
}
assertEquals(
expectedMoreTools,
defaultItems
.filter { it.type == PdfFlatItemType.MORE_TOOL }
.mapNotNull { it.tool }
.toSet()
)
assertFalse(defaultItems.any { it.tool?.name == "FULL_SCREEN" })
if (!BuildConfig.IS_PRO) {
assertFalse(defaultItems.any { it.tool == PdfReaderTool.OCR_LANGUAGE })
}
}
@Test
fun `pdf overflow sections end at reflow when all file actions are hidden`() {
val sections = pdfOverflowMenuSections(
hiddenTools = setOf(
PdfReaderTool.SHARE.name,
PdfReaderTool.SAVE_COPY.name,
PdfReaderTool.PRINT.name
),
hasHiddenToolbarTools = false,
isPro = false,
effectiveFileType = FileType.PDF,
hasFileInfo = false
)
assertEquals(PdfOverflowMenuSection.REFLOW, sections.last())
assertTrue(PdfOverflowMenuSection.FILE_ACTIONS !in sections)
}
@Test
fun `pdf overflow sections keep file actions when only print is hidden`() {
val sections = pdfOverflowMenuSections(
hiddenTools = setOf(PdfReaderTool.PRINT.name),
hasHiddenToolbarTools = false,
isPro = false,
effectiveFileType = FileType.PDF,
hasFileInfo = false
)
assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last())
}
@Test
fun `pdf overflow sections expose file info only when available and visible`() {
val visibleSections = pdfOverflowMenuSections(
hiddenTools = emptySet(),
hasHiddenToolbarTools = false,
isPro = false,
effectiveFileType = FileType.PDF,
hasFileInfo = true
)
val missingItemSections = pdfOverflowMenuSections(
hiddenTools = emptySet(),
hasHiddenToolbarTools = false,
isPro = false,
effectiveFileType = FileType.PDF,
hasFileInfo = false
)
val hiddenSections = pdfOverflowMenuSections(
hiddenTools = setOf(PdfReaderTool.FILE_INFO.name),
hasHiddenToolbarTools = false,
isPro = false,
effectiveFileType = FileType.PDF,
hasFileInfo = true
)
assertTrue(PdfOverflowMenuSection.FILE_INFO in visibleSections)
assertEquals(PdfOverflowMenuSection.FILE_INFO, visibleSections.last())
assertFalse(PdfOverflowMenuSection.FILE_INFO in missingItemSections)
assertFalse(PdfOverflowMenuSection.FILE_INFO in hiddenSections)
}
}

View file

@ -0,0 +1,28 @@
package com.aryan.reader.pdf
import java.io.File
import org.junit.Assert.assertTrue
import org.junit.Test
class PdfReleaseRulesTest {
@Test
fun `release rules keep pdf reader and pdfium internals`() {
val rules = readProguardRules()
assertTrue(rules.contains("-keep class com.aryan.reader.pdf.PdfViewerScreenKt"))
assertTrue(rules.contains("-keep class com.aryan.reader.pdf.PdfPageComposableKt"))
assertTrue(rules.contains("-keep class io.legere.pdfiumandroid.**"))
assertTrue(rules.contains("-keep class com.aryan.reader.pdf.NativePdfiumBridge"))
}
private fun readProguardRules(): String {
val candidates = listOf(
File("proguard-rules.pro"),
File("app/proguard-rules.pro")
)
val file = candidates.firstOrNull { it.isFile }
requireNotNull(file) { "Unable to locate app proguard-rules.pro" }
return file.readText()
}
}

View file

@ -0,0 +1,39 @@
package com.aryan.reader.pdf
import androidx.compose.ui.graphics.Color
import com.aryan.reader.ReaderTheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test
class PdfVerticalReaderThemeTest {
@Test
fun `vertical background falls back when theme color is unspecified`() {
val theme = ReaderTheme(
id = "custom-without-background",
name = "Custom",
backgroundColor = Color.Unspecified,
textColor = Color.Black,
isDark = false
)
val background = resolvePdfVerticalPageBackgroundColor(theme)
assertEquals(Color.White, background)
assertNotEquals(Color.Unspecified, background)
}
@Test
fun `vertical background keeps explicit reverse theme black`() {
val theme = ReaderTheme(
id = "reverse",
name = "Reverse",
backgroundColor = Color.White,
textColor = Color.Black,
isDark = true
)
assertEquals(Color.Black, resolvePdfVerticalPageBackgroundColor(theme))
}
}

View file

@ -1,5 +1,8 @@
package com.aryan.reader.pdf
import androidx.compose.ui.geometry.Offset
import com.aryan.reader.shared.reader.ReaderPageSpreadMode
import com.aryan.reader.shared.reader.ReaderSettings
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -128,4 +131,69 @@ class PdfZoomLockStateTest {
0.0001f
)
}
@Test
fun `pdf page range labels preserve single page and spread wording`() {
val singlePageSettings = ReaderSettings()
val spreadSettings = ReaderSettings(pageSpreadMode = ReaderPageSpreadMode.TWO_PAGE)
assertEquals(
"5 / 5",
pdfPageRangeText(
pageIndex = 99,
pageCount = 5,
displayMode = DisplayMode.VERTICAL_SCROLL,
settings = singlePageSettings
)
)
assertEquals(
"Page 5 of 5",
pdfPageRangeLabel(
pageIndex = 99,
pageCount = 5,
displayMode = DisplayMode.VERTICAL_SCROLL,
settings = singlePageSettings
)
)
assertEquals(
"3-4 / 10",
pdfPageRangeText(
pageIndex = 2,
pageCount = 10,
displayMode = DisplayMode.PAGINATION,
settings = spreadSettings
)
)
assertEquals(
"Pages 3-4 of 10",
pdfPageRangeLabel(
pageIndex = 2,
pageCount = 10,
displayMode = DisplayMode.PAGINATION,
settings = spreadSettings
)
)
}
@Test
fun `spread camera clamp keeps offset inside scaled viewport bounds`() {
val clamped = clampPdfSpreadCameraOffset(
scale = 2f,
offset = Offset(100f, -100f),
viewportWidth = 100f,
viewportHeight = 80f
)
assertEquals(50f, clamped.x, 0.0001f)
assertEquals(-40f, clamped.y, 0.0001f)
assertEquals(
Offset.Zero,
clampPdfSpreadCameraOffset(
scale = 1f,
offset = Offset(20f, 20f),
viewportWidth = 100f,
viewportHeight = 80f
)
)
}
}

View file

@ -7,6 +7,7 @@ import androidx.compose.ui.graphics.toArgb
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfTextBox
import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.shared.pdf.SharedPdfAnnotationComment
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@ -19,7 +20,7 @@ import org.robolectric.RobolectricTestRunner
class PdfiumAnnotationExporterTest {
@Test
fun `buildPayload flattens ink annotations and skips unsupported ink tools`() {
fun `buildPayload flattens ink annotations and skips unsupported ink and text annotations`() {
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = mapOf(
2 to listOf(
@ -29,7 +30,8 @@ class PdfiumAnnotationExporterTest {
pageIndex = 99,
points = listOf(PdfPoint(0.1f, 0.2f), PdfPoint(0.3f, 0.4f)),
color = Color(0xFF336699),
strokeWidth = 0.0125f
strokeWidth = 0.0125f,
id = "ink-1"
),
PdfAnnotation(
type = AnnotationType.INK,
@ -38,6 +40,14 @@ class PdfiumAnnotationExporterTest {
points = listOf(PdfPoint(0.5f, 0.6f), PdfPoint(0.7f, 0.8f)),
color = Color.Black,
strokeWidth = 0.1f
),
PdfAnnotation(
type = AnnotationType.TEXT,
inkType = InkType.PEN,
pageIndex = 2,
points = listOf(PdfPoint(0.2f, 0.3f), PdfPoint(0.4f, 0.5f)),
color = Color.Black,
strokeWidth = 0.1f
)
)
),
@ -52,10 +62,36 @@ class PdfiumAnnotationExporterTest {
assertArrayEquals(intArrayOf(0), payload.inkPointOffsets)
assertArrayEquals(intArrayOf(2), payload.inkPointCounts)
assertArrayEquals(floatArrayOf(0.1f, 0.2f, 0.3f, 0.4f), payload.inkPoints, 0.0001f)
assertEquals("", payload.inkContents.single())
assertEquals("ink-1", payload.inkNames.single())
}
@Test
fun `buildPayload preserves highlight pdf rects and content notes`() {
fun `buildPayload trims chisel highlighter endpoints for pdf ink caps`() {
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = mapOf(
0 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.HIGHLIGHTER,
pageIndex = 0,
points = listOf(PdfPoint(0.1f, 0.2f), PdfPoint(0.9f, 0.2f)),
color = Color(0x8CFFEB3B.toInt()),
strokeWidth = 0.1f,
id = "highlighter"
)
)
),
textBoxes = emptyList(),
highlights = emptyList(),
pageSizes = listOf(PdfiumPageSize(width = 100, height = 100))
)
assertArrayEquals(floatArrayOf(0.165f, 0.2f, 0.835f, 0.2f), payload.inkPoints, 0.0001f)
}
@Test
fun `buildPayload normalizes highlight rects and preserves names and content notes`() {
val payload = PdfiumAnnotationExporter.buildPayload(
inkAnnotations = emptyMap(),
textBoxes = emptyList(),
@ -67,8 +103,27 @@ class PdfiumAnnotationExporterTest {
color = PdfHighlightColor.BLUE,
text = "Selected text",
range = 0 to 13,
note = "Important"
note = "Important",
comments = listOf(
SharedPdfAnnotationComment(
id = "comment-1",
author = "Ada",
contents = "First comment",
createdAt = 1_700_000_000_000L,
modifiedAt = 1_700_000_010_000L
),
SharedPdfAnnotationComment(
id = "comment-2",
author = "Bea",
contents = "Second top-level comment",
createdAt = 1_700_000_020_000L
)
)
)
),
pageSizes = listOf(
PdfiumPageSize(width = 612, height = 792),
PdfiumPageSize(width = 100, height = 100)
)
)
@ -77,11 +132,23 @@ class PdfiumAnnotationExporterTest {
assertArrayEquals(intArrayOf(0), payload.highlightRectOffsets)
assertArrayEquals(intArrayOf(2), payload.highlightRectCounts)
assertArrayEquals(
floatArrayOf(10f, 90f, 40f, 80f, 50f, 70f, 60f, 65f),
floatArrayOf(0.1f, 0.1f, 0.4f, 0.2f, 0.5f, 0.3f, 0.6f, 0.35f),
payload.highlightRects,
0.0001f
)
assertEquals("highlight-1", payload.highlightNames.single())
assertEquals("Important", payload.highlightContents.single())
assertArrayEquals(intArrayOf(0), payload.highlightCommentOffsets)
assertArrayEquals(intArrayOf(1), payload.highlightCommentCounts)
assertArrayEquals(intArrayOf(-1), payload.highlightCommentParentIndices)
assertEquals(listOf("highlight-1_comments"), payload.highlightCommentNames.toList())
assertEquals(listOf("Ada"), payload.highlightCommentAuthors.toList())
assertEquals(
listOf("Ada:\nFirst comment\n\nBea:\nSecond top-level comment"),
payload.highlightCommentContents.toList()
)
assertEquals("D:20231114221320Z", payload.highlightCommentCreatedDates[0])
assertEquals("D:20231114221340Z", payload.highlightCommentModifiedDates[0])
}
@Test

View file

@ -0,0 +1,142 @@
package com.aryan.reader.tts
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
class TtsChunkNavigationTest {
@Test
fun `chunk skip target moves one chunk at a time`() {
assertEquals(1, resolveTtsChunkSkipTarget(currentChunkIndex = 2, totalChunks = 5, direction = -1))
assertEquals(3, resolveTtsChunkSkipTarget(currentChunkIndex = 2, totalChunks = 5, direction = 1))
}
@Test
fun `chunk skip target is absent at boundaries`() {
assertNull(resolveTtsChunkSkipTarget(currentChunkIndex = 0, totalChunks = 5, direction = -1))
assertNull(resolveTtsChunkSkipTarget(currentChunkIndex = 4, totalChunks = 5, direction = 1))
}
@Test
fun `chunk skip target is absent for invalid state`() {
assertNull(resolveTtsChunkSkipTarget(currentChunkIndex = -1, totalChunks = 5, direction = 1))
assertNull(resolveTtsChunkSkipTarget(currentChunkIndex = 5, totalChunks = 5, direction = -1))
assertNull(resolveTtsChunkSkipTarget(currentChunkIndex = 0, totalChunks = 0, direction = 1))
assertNull(resolveTtsChunkSkipTarget(currentChunkIndex = 0, totalChunks = 5, direction = 0))
assertNull(resolveTtsChunkSkipTarget(currentChunkIndex = 0, totalChunks = 5, direction = 2))
}
@Test
fun `start chunk index is clamped to available chunks`() {
assertEquals(2, resolveTtsStartChunkIndex(requestedChunkIndex = 2, totalChunks = 5))
assertEquals(0, resolveTtsStartChunkIndex(requestedChunkIndex = -1, totalChunks = 5))
assertEquals(4, resolveTtsStartChunkIndex(requestedChunkIndex = 7, totalChunks = 5))
assertEquals(0, resolveTtsStartChunkIndex(requestedChunkIndex = 2, totalChunks = 0))
}
@Test
fun `only forward chunk skips can reuse an existing playlist item`() {
assertEquals(3, resolveReusableTtsPlaylistIndex(playlistIndex = 3, direction = 1))
assertNull(resolveReusableTtsPlaylistIndex(playlistIndex = 3, direction = -1))
assertNull(resolveReusableTtsPlaylistIndex(playlistIndex = null, direction = 1))
assertNull(resolveReusableTtsPlaylistIndex(playlistIndex = -1, direction = 1))
}
@Test
fun `automatic playlist advance must stay contiguous by chunk id`() {
assertEquals(true, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = 9))
assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = 10))
assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null))
}
@Test
fun `transition prefetch is deferred only for the rebuilding generation`() {
assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6))
assertEquals(true, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 5))
assertEquals(true, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = -1))
}
@androidx.annotation.OptIn(UnstableApi::class)
@Test
fun `reader tts mini bar is visible only for active reader playback outside reader routes`() {
val activeReaderState = TtsPlaybackManager.TtsState(
currentText = "Playing text",
playbackSource = "READER"
)
assertEquals(true, shouldShowReaderTtsMiniBar(activeReaderState, isOnReaderRoute = false))
assertEquals(false, shouldShowReaderTtsMiniBar(activeReaderState, isOnReaderRoute = true))
assertEquals(
false,
shouldShowReaderTtsMiniBar(activeReaderState.copy(playbackSource = "OTHER"), isOnReaderRoute = false)
)
assertEquals(
false,
shouldShowReaderTtsMiniBar(activeReaderState.copy(sessionFinished = true), isOnReaderRoute = false)
)
assertEquals(
false,
shouldShowReaderTtsMiniBar(activeReaderState.copy(sessionEndedByStop = true), isOnReaderRoute = false)
)
}
@Test
fun `reader tts mini bar clears main bottom navigation`() {
assertEquals(96, readerTtsMiniBarBottomPaddingDp(isOnMainRoute = true))
assertEquals(16, readerTtsMiniBarBottomPaddingDp(isOnMainRoute = false))
}
@Test
fun `stream pcm duration uses cloud tts audio format`() {
assertEquals(1_000L, resolveTtsStreamPcmDurationMs(totalBytes = 44L + 48_000L))
assertNull(resolveTtsStreamPcmDurationMs(totalBytes = 44L))
assertNull(resolveTtsStreamPcmDurationMs(totalBytes = 0L))
}
@Test
fun `notification duration estimate stays ahead of playback position`() {
assertEquals(1_500L, estimateTtsNotificationDurationMs(text = "one two"))
assertEquals(5_000L, estimateTtsNotificationDurationMs(text = "one", currentPositionMs = 3_000L))
assertNull(estimateTtsNotificationDurationMs(text = " "))
}
@Test
fun `wav file duration is read from pcm byte rate`() {
val file = createTempWavFile(pcmBytes = 48_000)
try {
assertEquals(1_000L, resolveWavFileDurationMs(file))
} finally {
file.delete()
}
}
private fun createTempWavFile(pcmBytes: Int): File {
val file = File.createTempFile("tts_chunk_navigation_", ".wav")
val header = ByteBuffer.allocate(44).order(ByteOrder.LITTLE_ENDIAN).apply {
put("RIFF".toByteArray(Charsets.US_ASCII))
putInt(36 + pcmBytes)
put("WAVE".toByteArray(Charsets.US_ASCII))
put("fmt ".toByteArray(Charsets.US_ASCII))
putInt(16)
putShort(1.toShort())
putShort(1.toShort())
putInt(24_000)
putInt(48_000)
putShort(2.toShort())
putShort(16.toShort())
put("data".toByteArray(Charsets.US_ASCII))
putInt(pcmBytes)
}.array()
file.outputStream().use { output ->
output.write(header)
output.write(ByteArray(pcmBytes))
}
return file
}
}