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:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]))"
|
||||
)
|
||||
}
|
||||
}
|
||||
34
app/src/test/java/com/aryan/reader/AppFontResolverTest.kt
Normal file
34
app/src/test/java/com/aryan/reader/AppFontResolverTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
124
app/src/test/java/com/aryan/reader/pdf/PdfOneHandZoomTest.kt
Normal file
124
app/src/test/java/com/aryan/reader/pdf/PdfOneHandZoomTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
142
app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt
Normal file
142
app/src/test/java/com/aryan/reader/tts/TtsChunkNavigationTest.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue