Linux support (#381)

* Add desktop release CI and support for Arch Linux packaging

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Make Gradle wrapper executable in desktop-release workflow

* Configure Gradle and update Java environment in desktop-release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Update Java setup and AUR packaging in desktop release workflow

* Add MSIX packaging support for Windows desktop distribution

* Update AUR packaging metadata and validation

* Use spine toc attribute for NCX resolution

* crash fixes

* Implement automatic discovery and injection of EPUB font face siblings

* Enhance custom font support with family grouping and variable font handling

* Optimize metadata loading and improve TTS highlighting

* Add keyboard navigation support for EPUB reader

* Refine PDF spread page sizing to respect aspect ratios

* Implement responsive maximum height for reader popups and sheets

* Handle TTS generation failures by skipping problematic chunks

* Refactor PDF tile rendering logic and zoom indicator behavior

* Prefer block and offset locators over page index in native vertical flow

* Implement save and share actions for original book files

* Add Estonian language support

* Implement temporary viewing mode for external files

* Implement direct opening for temporary external files without library persistence

* fix failing tests

* Import SharedFileCapabilities in DesktopLibraryUi

* Improve native vertical reader progress, persistence, and image support

* Center target in viewport for native vertical reader and support animated scrolling
This commit is contained in:
Aryan 2026-06-14 13:43:49 +05:30 committed by GitHub
parent a13d6599d1
commit 625a4d5d2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 6012 additions and 687 deletions

View file

@ -12,16 +12,28 @@ class AndroidStringFormatResourcesTest {
@Test
fun `vietnamese strings cover translatable base resources`() {
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-vi", localeName = "Vietnamese")
}
@Test
fun `estonian strings cover translatable base resources`() {
assertLocaleCoversTranslatableBaseResources(localeDirectory = "values-et", localeName = "Estonian")
}
private fun assertLocaleCoversTranslatableBaseResources(
localeDirectory: String,
localeName: String
) {
val resDirectory = findResDirectory()
val baseNames = readResourceNames(
stringsFile = File(resDirectory, "values/strings.xml"),
includeNonTranslatable = false
)
val vietnameseNames = readResourceNames(File(resDirectory, "values-vi/strings.xml"))
val missingNames = baseNames.filterNot { it in vietnameseNames }
val localizedNames = readResourceNames(File(resDirectory, "$localeDirectory/strings.xml"))
val missingNames = baseNames.filterNot { it in localizedNames }
assertTrue(
"Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}",
"Missing $localeName strings:\n${missingNames.joinToString(separator = "\n")}",
missingNames.isEmpty()
)
}

View file

@ -15,11 +15,11 @@ class AppLanguageOptionsTest {
assertEquals(
listOf(
"en", "ar", "de", "nl", "tr", "fr", "ru", "uk", "be", "es", "pt-BR", "it", "pl",
"id", "vi", "ja", "ko", "hi", "zh-CN"
"id", "vi", "ja", "ko", "hi", "zh-CN", "et"
),
supportedAppLanguageOptions.mapNotNull { it.tag }
)
assertEquals(R.string.language_chinese_simplified, supportedAppLanguageOptions.last().labelRes)
assertEquals(R.string.language_estonian, supportedAppLanguageOptions.last().labelRes)
}
@Test
@ -51,6 +51,7 @@ class AppLanguageOptionsTest {
val vietnamese = supportedAppLanguageOptions.first { it.tag == "vi" }
val japanese = supportedAppLanguageOptions.first { it.tag == "ja" }
val korean = supportedAppLanguageOptions.first { it.tag == "ko" }
val estonian = supportedAppLanguageOptions.first { it.tag == "et" }
assertTrue(turkish.matchesLanguageSearch(label = "Türkçe (Turkish)", query = "turkce"))
assertTrue(dutch.matchesLanguageSearch(label = "Nederlands", query = "dutch"))
@ -66,6 +67,7 @@ class AppLanguageOptionsTest {
assertTrue(vietnamese.matchesLanguageSearch(label = "Tiếng Việt", query = "tieng viet"))
assertTrue(japanese.matchesLanguageSearch(label = "日本語", query = "nihongo"))
assertTrue(korean.matchesLanguageSearch(label = "한국어", query = "hangul"))
assertTrue(estonian.matchesLanguageSearch(label = "Eesti", query = "eesti"))
}
@Test

View file

@ -0,0 +1,17 @@
package com.aryan.reader
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ClipboardUtilsTest {
@Test
fun `set primary clip reports success`() {
assertTrue(setPrimaryClipSafely {})
}
@Test
fun `set primary clip handles security rejection`() {
assertFalse(setPrimaryClipSafely { throw SecurityException("denied") })
}
}

View file

@ -0,0 +1,28 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class ExternalFileOpenRouteDeciderTest {
@Test
fun `temporary behavior routes to temporary activity`() {
assertTrue(ExternalFileOpenRouteDecider.shouldOpenTemporary("TEMPORARY"))
assertEquals(
TemporaryExternalFileActivity::class.java,
ExternalFileOpenRouteDecider.targetActivityClass("TEMPORARY")
)
}
@Test
fun `existing behaviors route to main activity`() {
listOf(null, "ASK", "KEEP", "DELETE").forEach { behavior ->
assertFalse(ExternalFileOpenRouteDecider.shouldOpenTemporary(behavior))
assertEquals(
MainActivity::class.java,
ExternalFileOpenRouteDecider.targetActivityClass(behavior)
)
}
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader
import android.app.Application
import android.content.ContentResolver
import android.content.SharedPreferences
import android.content.res.Resources
import android.net.Uri
@ -22,6 +23,7 @@ import com.aryan.reader.tts.TtsPlaybackManager
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
@ -45,6 +47,7 @@ class MainViewModelTest {
private lateinit var mockApplication: Application
private lateinit var mockPrefs: SharedPreferences
private lateinit var mockEditor: SharedPreferences.Editor
private val prefsStringSets = mutableMapOf<String, Set<String>>()
private val billingStateFlow = MutableStateFlow(ProUpgradeState())
private val customFontsFlow = MutableStateFlow<List<CustomFontEntity>>(emptyList())
@ -64,6 +67,12 @@ class MainViewModelTest {
}
private class TestMainViewModel(application: Application) : MainViewModel(application) {
val locallyCleanedBookIds = mutableListOf<String>()
override suspend fun cleanupBookDataLocally(bookId: String) {
locallyCleanedBookIds += bookId
}
fun clearForTest() {
ViewModel::class.java
.getDeclaredMethod("clear\$lifecycle_viewmodel_release")
@ -83,6 +92,7 @@ class MainViewModelTest {
billingStateFlow.value = ProUpgradeState()
customFontsFlow.value = emptyList()
ttsStateFlow.value = TtsPlaybackManager.TtsState()
prefsStringSets.clear()
mockkStatic(Log::class)
every { Log.isLoggable(any(), any()) } returns false
@ -109,9 +119,14 @@ class MainViewModelTest {
every { mockApplication.filesDir } returns filesDir
every { mockApplication.cacheDir } returns cacheDir
every { mockApplication.getExternalFilesDir(any()) } returns externalFilesDir
every { mockApplication.getString(any()) } answers { "res-${firstArg<Int>()}" }
every { mockApplication.getString(any(), *anyVararg()) } answers { "res-${firstArg<Int>()}" }
every { mockPrefs.edit() } returns mockEditor
every { mockPrefs.getString(any(), any()) } answers { secondArg() as String? }
every { mockPrefs.getStringSet(any(), any()) } answers {
prefsStringSets[firstArg<String>()]?.toMutableSet() ?: secondArg<Set<String>?>()?.toMutableSet()
}
every { mockPrefs.getBoolean(any(), any()) } answers { secondArg() as Boolean }
every { mockPrefs.getInt(any(), any()) } answers { secondArg() as Int }
every { mockPrefs.getFloat(any(), any()) } answers { secondArg() as Float }
@ -174,6 +189,7 @@ class MainViewModelTest {
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().addRecentFile(any()) } just Runs
coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
@ -417,38 +433,39 @@ class MainViewModelTest {
viewModel.setStrictFileFilter(true)
viewModel.setUsePdfFileNameAsDisplayName(true)
viewModel.setExternalFileBehavior("KEEP")
viewModel.setExternalFileBehavior("TEMPORARY")
val state = viewModel.uiState.first {
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "KEEP"
it.useStrictFileFilter && it.usePdfFileNameAsDisplayName && it.externalFileBehavior == "TEMPORARY"
}
assertTrue(state.useStrictFileFilter)
assertTrue(state.usePdfFileNameAsDisplayName)
assertEquals("KEEP", state.externalFileBehavior)
assertEquals("TEMPORARY", 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") }
verify { mockEditor.putString("external_file_behavior", "TEMPORARY") }
}
@Test
fun `startup removes pending external always-remove file before restoring session`() = runTest(testDispatcher) {
val pendingUri = "file:///data/user/0/com.aryan.reader/files/books/external.epub"
val pendingEntry = """{"bookId":"external-book","uriString":"$pendingUri"}"""
every {
mockPrefs.getStringSet("pending_external_file_removals", any())
} returns mutableSetOf(pendingEntry)
prefsStringSets["pending_external_file_removals"] = setOf(pendingEntry)
every { mockPrefs.getString("last_open_book_id", null) } returns "external-book"
every { mockPrefs.getString("last_open_file_type", null) } returns FileType.EPUB.name
val restored = TestMainViewModel(mockApplication)
try {
advanceUntilIdle()
coVerify {
coVerify(timeout = 1_000) {
anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book"))
}
coVerify {
anyConstructed<BookImporter>().deleteBookByUriString(pendingUri)
}
assertEquals(listOf("external-book"), restored.locallyCleanedBookIds)
verify(atLeast = 1) { mockEditor.remove("last_open_book_id") }
verify(atLeast = 1) { mockEditor.remove("last_open_file_type") }
verify { mockEditor.remove("pending_external_file_removals") }
@ -457,6 +474,60 @@ class MainViewModelTest {
}
}
@Test
fun `temporary external pdf opens directly without importing or adding to library`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val externalUri = mockUri("content://external/temp.pdf", path = "/temp.pdf", lastPathSegment = "temp.pdf")
val resolver = mockk<ContentResolver>()
every { mockApplication.contentResolver } returns resolver
every { resolver.getType(externalUri) } returns "application/pdf"
every { resolver.query(externalUri, null, null, null, null) } returns null
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(match { it.startsWith("temporary-") }) } returns null
viewModel.onFileSelected(
externalUri,
isFromRecent = false,
isExternalIntent = true,
isTemporaryExternalIntent = true
)
advanceUntilIdle()
val selected = viewModel.uiState.first { it.selectedBookId?.startsWith("temporary-") == true && it.selectedPdfUri != null }
assertEquals(externalUri, selected.selectedPdfUri)
assertEquals(null, selected.showExternalFileSavePromptFor)
coVerify(exactly = 0) { anyConstructed<BookImporter>().importBook(any()) }
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().addRecentFile(any()) }
verify(exactly = 0) { mockEditor.putStringSet("pending_external_file_removals", any()) }
}
@Test
fun `closing temporary external direct book signals activity finish without library cleanup`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val item = recentFile("external-book", type = FileType.PDF)
coEvery { anyConstructed<RecentFilesRepository>().getFileByBookId(item.bookId) } returns item
viewModel.trackExternalOpenForClose(
bookId = item.bookId,
importedCopyUriString = null,
isTemporaryExternalIntent = true
)
viewModel.onRecentFileClicked(item)
advanceUntilIdle()
viewModel.uiState.first { it.selectedBookId == item.bookId }
val finishEvent = backgroundScope.async { viewModel.temporaryExternalOpenFinished.first() }
viewModel.clearSelectedFile()
advanceUntilIdle()
assertEquals(null, viewModel.uiState.value.showExternalFileSavePromptFor)
coVerify(exactly = 0) { anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf(item.bookId)) }
coVerify(exactly = 0) { anyConstructed<BookImporter>().deleteBookByUriString(item.uriString!!) }
assertTrue(finishEvent.isCompleted)
}
@Test
fun `screen capture protection persists and updates state`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {

View file

@ -0,0 +1,22 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Test
class ReaderPopupSizingTest {
@Test
fun `modal max height leaves edge margin on landscape-height screens`() {
assertEquals(306, readerModalMaxHeightDp(screenHeightDp = 360))
}
@Test
fun `modal max height uses preferred minimum when there is room`() {
assertEquals(220, readerModalMaxHeightDp(screenHeightDp = 252))
}
@Test
fun `modal max height stays within tiny screens`() {
assertEquals(168, readerModalMaxHeightDp(screenHeightDp = 200))
}
}

View file

@ -0,0 +1,32 @@
package com.aryan.reader.data
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class ImportedFontFileNameTest {
@Test
fun importedFontFileNamePreservesVariableFontVariantTokens() {
val fileName = importedFontFileName(
displayName = "Pliant-Italic-VariableFont_wdth,wght",
extension = "TTF"
)
assertEquals("Pliant-Italic-VariableFont_wdth,wght.ttf", fileName)
}
@Test
fun importedFontFileNameRemovesPathUnsafeCharacters() {
val fileName = importedFontFileName(
displayName = """Pliant/Italic:VariableFont*wdth?wght""",
extension = "t/tf"
)
assertEquals("Pliant_Italic_VariableFont_wdth_wght.ttf", fileName)
}
@Test
fun importedFontFileNameFallsBackForBlankNames() {
assertTrue(importedFontFileName("...", "ttf").startsWith("font."))
}
}

View file

@ -105,6 +105,26 @@ class RecentFileDaoReadingPositionTest {
assertTrue(item.isRecent)
}
@Test
fun `recent file summary caps oversized descriptions while full lookup keeps metadata`() = runTest {
val longDescription = "Summary ".repeat(2_000)
val longOriginalDescription = "Original ".repeat(2_000)
dao.insertOrUpdateFile(
recentFileEntity().copy(
description = longDescription,
originalDescription = longOriginalDescription
)
)
val summary = dao.getRecentFiles().first().single()
val full = dao.getFileByBookId("book-1")!!
assertEquals(4_096, summary.description?.length)
assertEquals(4_096, summary.originalDescription?.length)
assertEquals(longDescription, full.description)
assertEquals(longOriginalDescription, full.originalDescription)
}
private fun recentFileEntity(lastPositionCfi: String? = null): RecentFileEntity {
return RecentFileEntity(
bookId = "book-1",

View file

@ -104,6 +104,30 @@ class EpubParserUnitTest {
assertTrue(extractionDir.list().isNullOrEmpty())
}
@Test
fun `createEpubBook uses spine toc id when manifest contains volume ncx files first`() = runTest {
val cacheDir = temp.newFolder("cache-merged-toc")
val extractionDir = temp.newFolder("extract-merged-toc")
val parser = EpubParser(contextWithCache(cacheDir))
val book = parser.createEpubBook(
inputStream = ByteArrayInputStream(mergedVolumeTocEpubBytes()),
bookId = "book-id",
shouldUseToc = true,
originalBookNameHint = "merged.epub",
parseContent = true,
extractionDirOverride = extractionDir
)
assertEquals(
listOf("Volume 1", "Chapter 1", "Volume 2", "Chapter 2"),
book.tableOfContents.map { it.label }
)
assertEquals(listOf(0, 1, 0, 1), book.tableOfContents.map { it.depth })
assertEquals("Volume 2", book.chapters[2].title)
assertEquals("Chapter 2", book.chapters[3].title)
}
@Test
fun `metadata only extraction streams images to disk without retaining image bytes`() {
val cacheDir = temp.newFolder("cache-metadata-stream")
@ -449,6 +473,50 @@ class EpubParserUnitTest {
"OEBPS/images/unlisted.png" to "not-real-image"
)
private fun mergedVolumeTocEpubBytes(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
""".trimIndent(),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata><dc:title>Merged Volumes</dc:title></metadata>
<manifest>
<item id="v1title" href="1/title.xhtml" media-type="application/xhtml+xml"/>
<item id="v1c1" href="1/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="v2title" href="2/title.xhtml" media-type="application/xhtml+xml"/>
<item id="v2c1" href="2/chapter1.xhtml" media-type="application/xhtml+xml"/>
<item id="v1ncx" href="1/toc.ncx" media-type="application/x-dtbncx+xml"/>
<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>
</manifest>
<spine toc="ncx">
<itemref idref="v1title"/>
<itemref idref="v1c1"/>
<itemref idref="v2title"/>
<itemref idref="v2c1"/>
</spine>
</package>
""".trimIndent(),
"OEBPS/1/toc.ncx" to """
<ncx><navMap>
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="title.xhtml"/></navPoint>
</navMap></ncx>
""".trimIndent(),
"OEBPS/toc.ncx" to """
<ncx><navMap>
<navPoint><navLabel><text>Volume 1</text></navLabel><content src="1/title.xhtml"/>
<navPoint><navLabel><text>Chapter 1</text></navLabel><content src="1/chapter1.xhtml"/></navPoint>
</navPoint>
<navPoint><navLabel><text>Volume 2</text></navLabel><content src="2/title.xhtml"/>
<navPoint><navLabel><text>Chapter 2</text></navLabel><content src="2/chapter1.xhtml"/></navPoint>
</navPoint>
</navMap></ncx>
""".trimIndent(),
"OEBPS/1/title.xhtml" to "<html><body><h1>HTML Volume 1</h1><p>Volume one.</p></body></html>",
"OEBPS/1/chapter1.xhtml" to "<html><body><h1>HTML Chapter 1</h1><p>Chapter one.</p></body></html>",
"OEBPS/2/title.xhtml" to "<html><body><h1>HTML Volume 2</h1><p>Volume two.</p></body></html>",
"OEBPS/2/chapter1.xhtml" to "<html><body><h1>HTML Chapter 2</h1><p>Chapter two.</p></body></html>"
)
private fun minimalEpubBytesWithoutOptionalMetadata(): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>

View file

@ -0,0 +1,29 @@
package com.aryan.reader.epubreader
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class EpubReaderTtsHighlightAssetTest {
@Test
fun `tts highlight is constrained to one readable block and does not inherit spacing`() {
val js = epubReaderAsset().readText()
assertTrue(js.contains("const TTS_HIGHLIGHT_BLOCK_SELECTOR"))
assertTrue(js.contains("getTtsHighlightBlock(baseNode)"))
assertTrue(js.contains("document.createTreeWalker(highlightRoot, NodeFilter.SHOW_TEXT"))
assertTrue(js.contains("text-align-last: auto !important;"))
assertTrue(js.contains("letter-spacing: normal !important;"))
assertTrue(js.contains("word-spacing: normal !important;"))
}
private fun epubReaderAsset(): File {
val candidates = listOf(
File("src/main/assets/epub_reader.js"),
File("app/src/main/assets/epub_reader.js")
)
return candidates.firstOrNull { it.isFile }
?: error("Unable to locate epub_reader.js from ${File(".").absolutePath}")
}
}

View file

@ -62,4 +62,45 @@ class EpubTtsChunkMatchingTest {
assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget))
}
@Test
fun `vertical continuation falls back to loaded chunk boundary when resume match is unavailable`() {
val chunks = listOf(
TtsChunk("Loaded one", "/4/2", 0),
TtsChunk("Loaded two", "/4/4", 0),
TtsChunk("Remaining three", "/4/6", 0),
TtsChunk("Remaining four", "/4/8", 0)
)
assertEquals(
2,
resolveTtsContinuationStartIndex(
chunks = chunks,
loadedChunkCount = 2,
sourceCfi = "/does/not/match",
startOffsetInSource = 0,
currentText = "not present"
)
)
}
@Test
fun `vertical continuation starts after matched spoken chunk`() {
val chunks = listOf(
TtsChunk("Loaded one", "/4/2", 0),
TtsChunk("Loaded two", "/4/4", 0),
TtsChunk("Remaining three", "/4/6", 0)
)
assertEquals(
2,
resolveTtsContinuationStartIndex(
chunks = chunks,
loadedChunkCount = 1,
sourceCfi = "/4/4",
startOffsetInSource = 0,
currentText = "Loaded two"
)
)
}
}

View file

@ -0,0 +1,75 @@
package com.aryan.reader.paginatedreader
import android.view.KeyEvent
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class AndroidEpubKeyCommandsTest {
@Test
fun `left and right map to page changes`() {
assertEquals(
AndroidEpubKeyCommand.PREVIOUS_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_LEFT)
)
assertEquals(
AndroidEpubKeyCommand.NEXT_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT)
)
}
@Test
fun `left and right respect right to left pagination`() {
assertEquals(
AndroidEpubKeyCommand.NEXT_PAGE,
androidEpubKeyCommandOrNull(
KeyEvent.KEYCODE_DPAD_LEFT,
rightToLeftPagination = true
)
)
assertEquals(
AndroidEpubKeyCommand.PREVIOUS_PAGE,
androidEpubKeyCommandOrNull(
KeyEvent.KEYCODE_DPAD_RIGHT,
rightToLeftPagination = true
)
)
}
@Test
fun `up and down map to vertical scroll`() {
assertEquals(
AndroidEpubKeyCommand.SCROLL_UP,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_UP)
)
assertEquals(
AndroidEpubKeyCommand.SCROLL_DOWN,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_DOWN)
)
}
@Test
fun `page home and end keys map to reader navigation`() {
assertEquals(
AndroidEpubKeyCommand.PREVIOUS_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_UP)
)
assertEquals(
AndroidEpubKeyCommand.NEXT_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_PAGE_DOWN)
)
assertEquals(
AndroidEpubKeyCommand.FIRST_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_HOME)
)
assertEquals(
AndroidEpubKeyCommand.LAST_PAGE,
androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_MOVE_END)
)
}
@Test
fun `ctrl shortcuts are left for reader chrome and search handling`() {
assertNull(androidEpubKeyCommandOrNull(KeyEvent.KEYCODE_DPAD_RIGHT, isCtrlPressed = true))
}
}

View file

@ -0,0 +1,128 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class EpubFontFaceSiblingsTest {
@Test
fun expandFontFacesWithSiblings_addsItalicAndBoldItalicVariants() {
val root = createTempRoot()
val fontsDir = File(root, "OEBPS/fonts").apply { mkdirs() }
File(fontsDir, "Literata-Regular.ttf").writeText("regular")
File(fontsDir, "Literata-Italic.ttf").writeText("italic")
File(fontsDir, "Literata-BoldItalic.ttf").writeText("bold italic")
File(fontsDir, "Other-Italic.ttf").writeText("other")
val expanded = expandFontFacesWithSiblings(
fontFaces = listOf(
FontFaceInfo(
fontFamily = "literata",
src = "OEBPS/fonts/Literata-Regular.ttf",
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
)
),
extractionPath = root.absolutePath
)
assertEquals(3, expanded.size)
assertTrue(expanded.any { it.src == "OEBPS/fonts/Literata-Italic.ttf" && it.fontStyle == FontStyle.Italic })
assertTrue(
expanded.any {
it.src == "OEBPS/fonts/Literata-BoldItalic.ttf" &&
it.fontStyle == FontStyle.Italic &&
it.fontWeight == FontWeight.Bold
}
)
assertTrue(expanded.none { it.src.contains("Other") })
}
@Test
fun buildEpubFontFaceCss_emitsVariantDescriptorsForSiblings() {
val root = createTempRoot()
val fontsDir = File(root, "fonts").apply { mkdirs() }
File(fontsDir, "LoraRegular.ttf").writeText("regular")
File(fontsDir, "LoraBoldItalic.ttf").writeText("bold italic")
val css = buildEpubFontFaceCss(
fontFaces = listOf(
FontFaceInfo(
fontFamily = "lora",
src = "fonts/LoraRegular.ttf",
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
)
),
extractionPath = root.absolutePath
)
assertTrue(css.contains("font-family: 'lora'"))
assertTrue(css.contains("font-weight: 700"))
assertTrue(css.contains("font-style: italic"))
assertTrue(css.contains("LoraBoldItalic.ttf"))
}
@Test
fun expandFontFacesWithSiblings_groupsVariableRegularAndItalicFiles() {
val root = createTempRoot()
val fontsDir = File(root, "fonts").apply { mkdirs() }
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
val expanded = expandFontFacesWithSiblings(
fontFaces = listOf(
FontFaceInfo(
fontFamily = "pliant",
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
)
),
extractionPath = root.absolutePath
)
assertEquals(2, expanded.size)
assertTrue(
expanded.any {
it.src == "fonts/Pliant-Italic-VariableFont_wdth,wght.ttf" &&
it.fontStyle == FontStyle.Italic &&
it.fontWeight == FontWeight.Normal
}
)
}
@Test
fun buildEpubFontFaceCss_usesWeightRangeForVariableWeightFonts() {
val root = createTempRoot()
val fontsDir = File(root, "fonts").apply { mkdirs() }
File(fontsDir, "Pliant-VariableFont_wdth,wght.ttf").writeText("regular variable")
File(fontsDir, "Pliant-Italic-VariableFont_wdth,wght.ttf").writeText("italic variable")
val css = buildEpubFontFaceCss(
fontFaces = listOf(
FontFaceInfo(
fontFamily = "pliant",
src = "fonts/Pliant-VariableFont_wdth,wght.ttf",
fontWeight = FontWeight.Normal,
fontStyle = FontStyle.Normal
)
),
extractionPath = root.absolutePath
)
assertTrue(css.contains("font-weight: 100 900"))
assertTrue(css.contains("font-style: italic"))
assertTrue(css.contains("Pliant-Italic-VariableFont_wdth,wght.ttf"))
}
private fun createTempRoot(): File {
return kotlin.io.path.createTempDirectory("epub-font-siblings").toFile().also {
it.deleteOnExit()
}
}
}

View file

@ -1,6 +1,7 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class NativeVerticalLocationTest {
@ -27,4 +28,157 @@ class NativeVerticalLocationTest {
assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f))
assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f))
}
@Test
fun `scroll progress updates within visible item offset`() {
val weights = listOf(100, 300, 600)
assertEquals(
25f,
estimateNativeVerticalWeightedScrollProgressPercent(
itemWeights = weights,
firstVisibleItemIndex = 1,
firstVisibleItemScrollOffset = 500,
firstVisibleItemSize = 1000
),
0.001f
)
assertEquals(
40f,
estimateNativeVerticalWeightedScrollProgressPercent(
itemWeights = weights,
firstVisibleItemIndex = 1,
firstVisibleItemScrollOffset = 1000,
firstVisibleItemSize = 1000
),
0.001f
)
}
@Test
fun `chapter page info uses chapter local locator offset`() {
val pageInfo = nativeVerticalChapterPageInfo(
chapterCharOffset = 500,
chapterLengthChars = 1000,
chapterPageCount = 11,
compatPageIndex = 900,
chapterStartPageIndex = 850
)
assertEquals(6, pageInfo?.currentPage)
assertEquals(11, pageInfo?.totalPages)
}
@Test
fun `chapter page info falls back to absolute page within chapter`() {
val pageInfo = nativeVerticalChapterPageInfo(
chapterCharOffset = null,
chapterLengthChars = 0,
chapterPageCount = 7,
compatPageIndex = 24,
chapterStartPageIndex = 20
)
assertEquals(5, pageInfo?.currentPage)
assertEquals(7, pageInfo?.totalPages)
}
@Test
fun `chapter page info follows scroll weight within current chapter`() {
val pageInfo = nativeVerticalChapterPageInfoForScroll(
itemChapterIndices = listOf(0, 0, 1, 1),
itemWeights = listOf(100, 300, 100, 300),
firstVisibleItemIndex = 1,
firstVisibleItemScrollOffset = 500,
firstVisibleItemSize = 1000,
chapterPageCount = 9
)
assertEquals(6, pageInfo?.currentPage)
assertEquals(9, pageInfo?.totalPages)
}
@Test
fun `native vertical image model decodes svg data uris for coil svg fetcher`() {
val model = nativeVerticalImageModelData(
"data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2010%2010%22%3E%3Ccircle%20r%3D%225%22%2F%3E%3C%2Fsvg%3E"
)
assertTrue(model is SvgData)
assertEquals("""<svg viewBox="0 0 10 10"><circle r="5"/></svg>""", (model as SvgData).content)
}
@Test
fun `native vertical svg data uri decoding preserves plus signs`() {
assertEquals(
"""<svg><path d="M1+2"/></svg>""",
nativeVerticalSvgContentFromDataUri(
"data:image/svg+xml,%3Csvg%3E%3Cpath%20d%3D%22M1+2%22%2F%3E%3C%2Fsvg%3E"
)
)
}
@Test
fun `native vertical persistence locator prefers visible text range`() {
val location = NativeVerticalLocation(
locator = Locator(chapterIndex = 2, blockIndex = 10, charOffset = 100),
chapterIndex = 2,
progressPercent = 42f,
compatPageIndex = 20,
compatTotalPages = 100,
firstVisibleItemIndex = 4,
firstVisibleItemScrollOffset = 250,
firstVisibleItemSize = 1000,
isAtStart = false,
isAtEnd = false,
visibleTextRanges = listOf(
NativeVerticalVisibleTextRange(
chapterIndex = 2,
blockIndex = 10,
startCharOffset = 380,
endCharOffset = 520
)
)
)
assertEquals(Locator(chapterIndex = 2, blockIndex = 10, charOffset = 380), location.locatorForPersistence())
}
@Test
fun `native vertical initial restore does not fallback to compat page when locator exists`() {
assertEquals(
false,
shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator = true,
didLocatorScroll = false
)
)
assertEquals(
true,
shouldFallbackNativeVerticalInitialScrollToCompatPage(
hasInitialLocator = false,
didLocatorScroll = false
)
)
}
@Test
fun `native vertical tts follow centers target offset in viewport`() {
assertEquals(
100f,
nativeVerticalCenteredScrollDelta(
targetOffsetInViewport = 500f,
viewportHeight = 800f
),
0.001f
)
assertEquals(
-200f,
nativeVerticalCenteredScrollDelta(
targetOffsetInViewport = 200f,
viewportHeight = 800f
),
0.001f
)
}
}

View file

@ -76,7 +76,7 @@ class ReaderNavigationTargetsTest {
@Test
fun `native vertical initial prefetch is bounded around requested chapter`() {
assertEquals(
listOf(4, 5, 2),
listOf(4, 5),
nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3)
)
}

View file

@ -111,6 +111,20 @@ class PdfReaderCoreLogicTest {
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
}
@Test
fun `pdf encrypt marker detection matches trailer encrypt entry`() {
val bytes = "%PDF-1.7\ntrailer\n<< /Size 4 /Encrypt 2 0 R >>".toByteArray(Charsets.US_ASCII)
assertTrue(pdfBytesContainEncryptMarker(bytes))
}
@Test
fun `pdf encrypt marker detection ignores longer pdf names`() {
val bytes = "<< /EncryptMetadata false /Size 4 >>".toByteArray(Charsets.US_ASCII)
assertFalse(pdfBytesContainEncryptMarker(bytes))
}
@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 {
@ -383,6 +397,32 @@ class PdfReaderCoreLogicTest {
assertTrue(limitedScale >= 0.01f)
}
@Test
fun `spread page slot width fits page aspect instead of filling half landscape viewport`() {
val slotWidth = pdfSpreadPageSlotWidth(
containerWidth = 1920f,
containerHeight = 900f,
pageGap = 0f,
spreadPageCount = 2,
pageAspectRatio = 612f / 792f
)
assertEquals(695.4545f, slotWidth, 0.001f)
}
@Test
fun `spread page slot width caps pages to available spread width`() {
val slotWidth = pdfSpreadPageSlotWidth(
containerWidth = 1000f,
containerHeight = 900f,
pageGap = 20f,
spreadPageCount = 2,
pageAspectRatio = 1.4f
)
assertEquals(490f, slotWidth, 0.0001f)
}
@Test
fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() {
assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true))

View file

@ -281,6 +281,23 @@ class PdfReaderSettingsAndSharedModelsTest {
assertEquals(PdfOverflowMenuSection.FILE_ACTIONS, sections.last())
}
@Test
fun `pdf overflow sections hide file actions when only unavailable print remains`() {
val sections = pdfOverflowMenuSections(
hiddenTools = setOf(
PdfReaderTool.SHARE.name,
PdfReaderTool.SAVE_COPY.name
),
hasHiddenToolbarTools = false,
isPro = false,
effectiveFileType = FileType.PDF,
hasFileInfo = false,
canPrintDocument = false
)
assertFalse(PdfOverflowMenuSection.FILE_ACTIONS in sections)
}
@Test
fun `pdf overflow sections expose file info only when available and visible`() {
val visibleSections = pdfOverflowMenuSections(

View file

@ -96,6 +96,86 @@ class PdfZoomLockStateTest {
)
}
@Test
fun `vertical pdf high res tiles render for settled zoom below one hundred percent`() {
assertTrue(
shouldRenderPdfHighResTiles(
effectiveScale = 0.82f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = true,
isActivePage = true
)
)
}
@Test
fun `vertical pdf high res tiles skip exact one hundred percent unless page is large`() {
assertFalse(
shouldRenderPdfHighResTiles(
effectiveScale = 1f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = true,
isActivePage = true
)
)
assertTrue(
shouldRenderPdfHighResTiles(
effectiveScale = 1f,
targetWidthPx = 3200,
targetHeightPx = 1600,
isVerticalScroll = true,
isActivePage = true
)
)
}
@Test
fun `paginated pdf high res tiles keep existing zoom threshold`() {
assertFalse(
shouldRenderPdfHighResTiles(
effectiveScale = 0.82f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = false,
isActivePage = true
)
)
assertTrue(
shouldRenderPdfHighResTiles(
effectiveScale = 1.25f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = false,
isActivePage = true
)
)
assertFalse(
shouldRenderPdfHighResTiles(
effectiveScale = 1.25f,
targetWidthPx = 1080,
targetHeightPx = 1600,
isVerticalScroll = false,
isActivePage = false
)
)
}
@Test
fun `zoom indicator percent rounds displayed scale`() {
assertEquals(82, pdfZoomIndicatorPercent(0.824f))
assertEquals(83, pdfZoomIndicatorPercent(0.826f))
assertEquals(100, pdfZoomIndicatorPercent(0.996f))
}
@Test
fun `zoom indicator hides only at displayed one hundred percent`() {
assertFalse(shouldShowPdfZoomIndicator(100))
assertTrue(shouldShowPdfZoomIndicator(99))
assertTrue(shouldShowPdfZoomIndicator(125))
}
@Test
fun `page change preserves locked zoom scale only in paginated lock mode`() {
val lockedState = Triple(2.25f, -12f, 32f)

View file

@ -7,6 +7,7 @@ import org.junit.Test
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.concurrent.ConcurrentHashMap
class TtsChunkNavigationTest {
@Test
@ -53,6 +54,25 @@ class TtsChunkNavigationTest {
assertEquals(false, shouldAdvanceToTtsPlaylistChunk(currentChunkIndex = 8, playlistChunkIndex = null))
}
@Test
fun `automatic playlist advance can step over chunks marked skipped after generation failures`() {
assertEquals(
true,
shouldAdvanceToTtsPlaylistChunk(
currentChunkIndex = 8,
playlistChunkIndex = 10,
skippedChunkIndices = setOf(9)
)
)
assertEquals(10, resolveNextPlayableTtsChunkIndex(8, 12, setOf(9)))
}
@Test
fun `chunk generation gives up after bounded failures`() {
assertEquals(false, shouldGiveUpTtsChunkGeneration(failureCount = 1, maxFailures = 2))
assertEquals(true, shouldGiveUpTtsChunkGeneration(failureCount = 2, maxFailures = 2))
}
@Test
fun `transition prefetch is deferred only for the rebuilding generation`() {
assertEquals(false, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = 6))
@ -150,6 +170,16 @@ class TtsChunkNavigationTest {
assertNull(estimateTtsNotificationDurationMs(text = " "))
}
@Test
fun `stable sorted snapshot copies concurrent cache keys`() {
val cache = ConcurrentHashMap<Int, String>()
cache[3] = "three"
cache[1] = "one"
cache[2] = "two"
assertEquals(listOf(1, 2, 3), stableSortedIntSnapshot(cache.keys))
}
@Test
fun `wav file duration is read from pcm byte rate`() {
val file = createTempWavFile(pcmBytes = 48_000)