Windows ga (#358)

* Enhance Cloud TTS with navigation controls and shared UI overlay in desktop app

* Refactor Cloud TTS voice settings and remove standalone settings overlay on desktop app

* Persist reader window state and improve slider interaction in desktop app

* Improve PDF page transitions and refine focus management in desktop app

* Refactor scrollbar interaction and adjust desktop modal focus handling

* Improve PDF sidecar synchronization and cross-platform metadata compatibility

* Refactor PDF annotation comment logic to shared module and implement Desktop UI

* Refactor reader screen to use tap-to-toggle and full-width styling in desktop app

* Refactor reader workspace layout and chrome-panel interactions

* Implement global search keyboard shortcuts and focusable chrome layers

* Implement flavor-specific legal links and update the About UI

* Refactor reader UI controls on desktop app

* Enhance desktop folder sync with background metadata extraction and improved error handling

* Refactor Library UI and remove redundant Home tab in desktop app

* Add custom tooltips to reader icon buttons in desktop app

* Integrate app theme controls into reader interfaces on desktop

* Add right-to-left pagination support and improve focus restoration on desktop app

* Improve EPUB pagination geometry and diagnostic logging for layout cutoffs on desktop app

* Update desktop reader defaults and implement settings migration

* Implement block-based position tracking in ReaderLocator

* Enhance EPUB highlighting reliability in desktop app

* Add support for custom reader themes and update highlight palette logic in desktop app

* Replace the Tools panel with a "More" dropdown menu and refactor account UI

* Implement account profile header in desktop sidebar

* Implement cloud sync reliability improvements and sidebar toggle on desktop app

* Improve EPUB annotation synchronization and highlight mapping accuracy in desktop app

* Integrate WebView2 for EPUB vertical rendering on Windows

* Refactor reader layout logic and enhance WebView2 diagnostics

* Improve vertical reading layout and WebView2 resizing on Desktop

* Refine vertical reading mode layout and margin handling

* Enhance reader locator precision and Desktop mode-switching reliability

* Implement chapter-level caching and warm-start pagination in desktop app

* Replace bundled KCEF with native system webviews via SWT

* Refactor EPUB page info bar visibility and layout logic

* Improve PDF toolbar persistence and fix tab reactivation logic

* Enable multi-selection and bulk operations for custom fonts

* Refactor instrumentation tests

* Add EPUB UI test fixture and initial instrumentation tests

* Expand EpubReader UI tests and improve accessibility

* Add instrumentation tests and test tags for library and reader screens

* Enhance OPDS parser logic and catalog integration

* Add support for toggling local synchronization on a per-folder basis.

* Implement tri-state sizing for the TTS overlay

* Persist TTS overlay size across sessions

* Refactor reader brightness control and add incremental step buttons

* Improve CSS support, pagination control, and style-aware semantic caching

* Improve link handling, interaction, and diagnostics in the paginated reader

* crash fixes

* Implement persistent pending removal for external files

* Implement book-specific word replacements

* Add native vertical reading mode with custom renderer

* Implement text selection and navigation improvements for the native vertical reader

* Implement locator-based navigation and improved vertical scrolling in native vertical mode in epub

* Implement lazy loading and chapter prefetching for native vertical reader

* Improve window lifecycle and disposal handling on Desktop

* Optimize vertical reading performance in desktop app

* Enhance TTS start accuracy and diagnostic logging on desktop

* Refactor AI settings visibility on desktop

* Improve pagination height measurement and enhance cutoff diagnostics

* Implement lifecycle management and improve justified text splitting for pagination

* Refine AI usage tracking and force AI feature visibility on Desktop

* Add descriptive context comments and usage examples to string and plural resources.

* Optimize performance and memory usage in search and state mapping

* Replace reader page sliders with minimal slider and navigation controls

* Add support for CBT comic archives

* Harden file path validation and XML parsing to prevent security vulnerabilities

* Implement local account profile caching and optimize desktop performance

* Improve desktop persistence reliability and add Linux secure storage support

* Improved PDF zoom stability and layout prediction during zoom commits

* Improved PDF spread layout prediction, reader focus restoration, and account profile caching

* Enhance highlight precision and scoping using block-local offsets and CFIs

* Enhance cloud book content synchronization and background downloads

* Implement granular timestamp tracking for reading positions and PDF annotations

* Restrict diagnostic logging and stack traces to debug builds

* Refine PDF page gaps and reader chrome interaction logic

* Refactor PDF highlight rendering and overhaul Desktop sidebar UI

* Implement a new interaction dock and undo/redo history for PDF annotations in desktop

* Enhance PDF color picker and improve navigation scroll restoration

* Add highlight palette customization and improve selection menu UI in desktop app epub reader

* Enhance desktop shelf management and library organization
This commit is contained in:
Aryan 2026-06-02 00:51:42 +05:30 committed by GitHub
parent 5971eaa571
commit 83dcafa4b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
444 changed files with 47279 additions and 8096 deletions

View file

@ -0,0 +1,22 @@
package com.aryan.reader
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidLegalLinksTest {
@Test
fun `android oss flavor maps to oss legal pages`() {
val links = legalLinksForAndroidFlavor("oss")
assertTrue(links.privacyPolicyUrl.endsWith("/oss-privacy-policy.html"))
assertTrue(links.termsUrl.endsWith("/oss-terms-of-service.html"))
}
@Test
fun `android pro flavor maps to standard legal pages`() {
val links = legalLinksForAndroidFlavor("pro")
assertTrue(links.privacyPolicyUrl.endsWith("/privacy-policy.html"))
assertTrue(links.termsUrl.endsWith("/terms-and-conditions.html"))
}
}

View file

@ -2,6 +2,7 @@ package com.aryan.reader
import com.aryan.reader.shared.SharedSettingsAction
import com.aryan.reader.shared.SharedSettingsDestination
import com.aryan.reader.shared.SharedFeaturePolicy
import com.aryan.reader.shared.SharedSettingsHubModel
import com.aryan.reader.shared.SharedSettingsItemModel
import com.aryan.reader.shared.sharedSettingsHubModel
@ -37,24 +38,23 @@ class AndroidSettingsHubModelsTest {
@Test
fun `oss online settings hide sync rows but keep oss ai key settings`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(
currentUser = UserData(
uid = "user-id",
displayName = "Reader",
photoUrl = null,
email = "reader@example.com"
),
isProUser = true,
isSyncEnabled = true,
isFolderSyncEnabled = true
val input = androidSettingsHubInput(
uiState = ReaderScreenState(
currentUser = UserData(
uid = "user-id",
displayName = "Reader",
photoUrl = null,
email = "reader@example.com"
),
isOssBuild = true,
isOfflineBuild = false,
isDebugBuild = true
)
isProUser = true,
isSyncEnabled = true,
isFolderSyncEnabled = true
),
isOssBuild = true,
isOfflineBuild = false,
isDebugBuild = true
)
val model = sharedSettingsHubModel(input)
val actions = model.visibleNestedActions()
assertTrue(SharedSettingsAction.AI_SETTINGS in actions)
@ -65,6 +65,7 @@ class AndroidSettingsHubModelsTest {
assertFalse(SharedSettingsAction.DEVICE_MANAGEMENT in actions)
assertFalse(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in actions)
assertTrue(SharedSettingsAction.SUPPORT in actions)
assertEquals(SharedFeaturePolicy.OssOnline, input.featurePolicy)
assertEquals(
"TTS & AI",
model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title

View file

@ -10,6 +10,22 @@ import org.junit.Test
class AndroidStringFormatResourcesTest {
@Test
fun `vietnamese strings cover translatable base resources`() {
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 }
assertTrue(
"Missing Vietnamese strings:\n${missingNames.joinToString(separator = "\n")}",
missingNames.isEmpty()
)
}
@Test
fun `localized formatted strings use valid formatter syntax`() {
val resDirectory = findResDirectory()
@ -48,6 +64,28 @@ class AndroidStringFormatResourcesTest {
).first { it.isDirectory }
}
private fun readResourceNames(
stringsFile: File,
includeNonTranslatable: Boolean = true
): List<String> {
val document = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()
.parse(stringsFile)
val nodes = document.documentElement.childNodes
return buildList {
for (index in 0 until nodes.length) {
val node = nodes.item(index)
val attributes = node.attributes ?: continue
val name = attributes.getNamedItem("name")?.nodeValue ?: continue
val translatable = attributes.getNamedItem("translatable")?.nodeValue
if (includeNonTranslatable || translatable != "false") {
add(name)
}
}
}
}
private fun readStringResources(stringsFile: File): Map<String, String> {
val document = DocumentBuilderFactory.newInstance()
.newDocumentBuilder()

View file

@ -99,6 +99,19 @@ class AppLanguageOptionsTest {
assertEquals("true", autoStoreLocales!!.androidAttribute("value"))
}
@Test
fun `android manifest exposes cbt comic archive mime types`() {
val mimeTypes = readAndroidManifest()
.getElementsByTagName("data")
.asElements()
.mapNotNull { it.androidAttribute("mimeType") }
assertTrue("application/x-cbt" in mimeTypes)
assertTrue("application/vnd.comicbook+tar" in mimeTypes)
assertTrue("application/x-tar" in mimeTypes)
assertTrue("application/tar" in mimeTypes)
}
private fun readLocaleConfigTags(): List<String> {
val localeConfig = listOf(
File("src/main/res/xml/locales_config.xml"),

View file

@ -0,0 +1,73 @@
package com.aryan.reader
import com.aryan.reader.shared.ReaderBookReplacementPreferences
import com.aryan.reader.shared.ReaderWordReplacementRule
import org.jsoup.Jsoup
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class BookReplacementHtmlTest {
@Test
fun `html replacement rewrites visible text for matching book`() {
val document = Jsoup.parse(
"""
<html>
<body>
<p id="first">Alice &amp; Alice</p>
<a href="chapter.xhtml">Alice link</a>
</body>
</html>
""".trimIndent(),
)
val preferences = ReaderBookReplacementPreferences(
fileRules = mapOf(
"book" to listOf(rule(from = "Alice", to = "Alicia")),
),
)
val changed = applyBookReplacementsToHtmlDocument(document, preferences, "book")
assertTrue(changed)
assertEquals("Alicia & Alicia", document.selectFirst("p")?.text())
assertEquals("Alicia link", document.selectFirst("a")?.text())
assertEquals("chapter.xhtml", document.selectFirst("a")?.attr("href"))
}
@Test
fun `html replacement skips blocked script text`() {
val document = Jsoup.parse(
"""
<html>
<body>
<p>Alice</p>
<script>var name = "Alice";</script>
</body>
</html>
""".trimIndent(),
)
val preferences = ReaderBookReplacementPreferences(
fileRules = mapOf(
"book" to listOf(rule(from = "Alice", to = "Alicia")),
),
)
val changed = applyBookReplacementsToHtmlDocument(document, preferences, "book")
assertTrue(changed)
assertEquals("Alicia", document.selectFirst("p")?.text())
assertTrue(document.selectFirst("script")?.html()?.contains("Alice") == true)
}
private fun rule(
id: String = "rule",
from: String,
to: String,
): ReaderWordReplacementRule {
return ReaderWordReplacementRule(
id = id,
from = from,
to = to,
)
}
}

View file

@ -0,0 +1,97 @@
package com.aryan.reader
import com.aryan.reader.data.BookMetadata
import com.aryan.reader.data.RecentFileItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class CloudEpubAnnotationMetadataTest {
@Test
fun `remote epub annotations fill local null annotation fields without changing timestamp`() {
val local = localBook(
highlightsJson = null,
bookmarksJson = null,
lastModifiedTimestamp = 2_000L
)
val remote = remoteBook(
highlightsJson = """[{"cfi":"/4/2:1"}]""",
bookmarksJson = """["{\"cfi\":\"/4/2\",\"chapterTitle\":\"One\",\"snippet\":\"A\",\"chapterIndex\":0}"]""",
lastModifiedTimestamp = 3_000L
)
val merged = local.mergeRemoteEpubAnnotationMetadata(remote)
assertEquals(remote.highlightsJson, merged.highlightsJson)
assertEquals(remote.bookmarksJson, merged.bookmarksJson)
assertEquals(2_000L, merged.lastModifiedTimestamp)
}
@Test
fun `explicit local empty epub annotations are not replaced by remote annotations`() {
val local = localBook(
highlightsJson = "[]",
bookmarksJson = "[]"
)
val remote = remoteBook(
highlightsJson = """[{"cfi":"/4/2:1"}]""",
bookmarksJson = """["{\"cfi\":\"/4/2\",\"chapterTitle\":\"One\",\"snippet\":\"A\",\"chapterIndex\":0}"]"""
)
val merged = local.mergeRemoteEpubAnnotationMetadata(remote)
assertEquals("[]", merged.highlightsJson)
assertEquals("[]", merged.bookmarksJson)
}
@Test
fun `non epub books do not use epub annotation preservation guard`() {
val local = localBook(type = FileType.PDF, highlightsJson = null)
val remote = remoteBook(type = FileType.PDF.name, highlightsJson = """[{"cfi":"/4/2:1"}]""")
assertFalse(local.needsRemoteEpubAnnotationMetadataGuard())
assertEquals(local, local.mergeRemoteEpubAnnotationMetadata(remote))
}
@Test
fun `blank and empty annotation json are equivalent noops`() {
assertTrue(annotationJsonEquivalentForNoop(null, "[]"))
assertTrue(annotationJsonEquivalentForNoop("", "[]"))
assertFalse(annotationJsonEquivalentForNoop("""[{"id":"h1"}]""", "[]"))
}
private fun localBook(
type: FileType = FileType.EPUB,
highlightsJson: String? = null,
bookmarksJson: String? = null,
lastModifiedTimestamp: Long = 1_000L
): RecentFileItem {
return RecentFileItem(
bookId = "book-1",
uriString = "content://book",
type = type,
displayName = "Book.epub",
timestamp = 1_000L,
lastModifiedTimestamp = lastModifiedTimestamp,
bookmarksJson = bookmarksJson,
highlightsJson = highlightsJson
)
}
private fun remoteBook(
type: String = FileType.EPUB.name,
highlightsJson: String? = null,
bookmarksJson: String? = null,
lastModifiedTimestamp: Long = 2_000L
): BookMetadata {
return BookMetadata(
bookId = "book-1",
displayName = "Book.epub",
type = type,
lastModifiedTimestamp = lastModifiedTimestamp,
bookmarksJson = bookmarksJson,
highlightsJson = highlightsJson
)
}
}

View file

@ -0,0 +1,167 @@
package com.aryan.reader
import com.aryan.reader.data.BookMetadata
import com.aryan.reader.data.effectiveAnnotationModifiedTimestamp
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
class CloudPdfAnnotationSidecarDecisionsTest {
@Test
fun `layout-only sidecar does not block newer remote annotations`() {
val local = AndroidPdfCloudSidecarState(
hasInk = false,
inkTimestamp = 0L,
hasRichText = false,
richTextTimestamp = 0L,
hasLayout = true,
layoutTimestamp = 2_000L,
hasTextBoxes = false,
textBoxesTimestamp = 0L,
hasHighlights = false,
highlightsTimestamp = 0L
)
val localShouldUpload = shouldUploadLocalPdfCloudAnnotations(
localSidecars = local,
remoteHasAnnotations = true,
remoteAnnotationModifiedTimestamp = 1_500L
)
assertFalse(localShouldUpload)
assertTrue(
shouldDownloadRemotePdfCloudAnnotations(
localSidecars = local,
localAnnotationsShouldUpload = localShouldUpload,
remoteHasAnnotations = true,
remoteAnnotationModifiedTimestamp = 1_500L
)
)
}
@Test
fun `newer local ink payload uploads instead of downloading remote annotations`() {
val local = AndroidPdfCloudSidecarState(
hasInk = true,
inkTimestamp = 2_000L,
hasRichText = false,
richTextTimestamp = 0L,
hasLayout = true,
layoutTimestamp = 2_500L,
hasTextBoxes = false,
textBoxesTimestamp = 0L,
hasHighlights = false,
highlightsTimestamp = 0L
)
val localShouldUpload = shouldUploadLocalPdfCloudAnnotations(
localSidecars = local,
remoteHasAnnotations = true,
remoteAnnotationModifiedTimestamp = 1_500L
)
assertTrue(localShouldUpload)
assertFalse(
shouldDownloadRemotePdfCloudAnnotations(
localSidecars = local,
localAnnotationsShouldUpload = localShouldUpload,
remoteHasAnnotations = true,
remoteAnnotationModifiedTimestamp = 1_500L
)
)
}
@Test
fun `newer local deletion tombstone uploads instead of downloading remote annotations`() {
val local = AndroidPdfCloudSidecarState(
hasInk = false,
inkTimestamp = 0L,
hasDeletedInk = true,
deletedInkTimestamp = 2_000L,
hasRichText = false,
richTextTimestamp = 0L,
hasLayout = false,
layoutTimestamp = 0L,
hasTextBoxes = false,
textBoxesTimestamp = 0L,
hasHighlights = false,
highlightsTimestamp = 0L
)
val localShouldUpload = shouldUploadLocalPdfCloudAnnotations(
localSidecars = local,
remoteHasAnnotations = true,
remoteAnnotationModifiedTimestamp = 1_500L
)
assertTrue(localShouldUpload)
assertFalse(
shouldDownloadRemotePdfCloudAnnotations(
localSidecars = local,
localAnnotationsShouldUpload = localShouldUpload,
remoteHasAnnotations = true,
remoteAnnotationModifiedTimestamp = 1_500L
)
)
}
@Test
fun `newer remote metadata alone does not make equal annotation payload download`() {
val local = AndroidPdfCloudSidecarState(
hasInk = true,
inkTimestamp = 2_000L,
hasRichText = false,
richTextTimestamp = 0L,
hasLayout = false,
layoutTimestamp = 0L,
hasTextBoxes = false,
textBoxesTimestamp = 0L,
hasHighlights = false,
highlightsTimestamp = 0L
)
val localShouldUpload = shouldUploadLocalPdfCloudAnnotations(
localSidecars = local,
remoteHasAnnotations = true,
remoteAnnotationModifiedTimestamp = 2_000L
)
assertFalse(localShouldUpload)
assertFalse(
shouldDownloadRemotePdfCloudAnnotations(
localSidecars = local,
localAnnotationsShouldUpload = localShouldUpload,
remoteHasAnnotations = true,
remoteAnnotationModifiedTimestamp = 2_000L
)
)
}
@Test
fun `annotation freshness does not fall back to book metadata timestamp`() {
val remote = BookMetadata(
bookId = "book-1",
lastModifiedTimestamp = 5_000L,
hasAnnotations = true
)
assertEquals(0L, remote.effectiveAnnotationModifiedTimestamp())
assertEquals(3_000L, remote.effectiveAnnotationModifiedTimestamp(sidecarModifiedTimestamp = 3_000L))
}
@Test
fun `empty sidecar placeholder is not syncable annotation payload`() {
assertFalse(tempSidecar("[]").hasSyncableCloudAnnotationPayload())
assertFalse(tempSidecar("{}").hasSyncableCloudAnnotationPayload())
assertTrue(tempSidecar("[{\"pageIndex\":0}]").hasSyncableCloudAnnotationPayload())
}
private fun tempSidecar(content: String): File {
return File.createTempFile("cloud-sidecar", ".json").apply {
writeText(content)
deleteOnExit()
}
}
}

View file

@ -34,8 +34,10 @@ class FileTypeResolverTest {
assertEquals(FileType.TXT, resolveFileTypeFromMetadata("notes", "text/plain"))
assertEquals(FileType.HTML, resolveFileTypeFromMetadata("payload", "application/json"))
assertEquals(FileType.CBZ, resolveFileTypeFromMetadata("comic.cbz", "application/zip"))
assertEquals(FileType.CBT, resolveFileTypeFromMetadata("comic.cbt", "application/x-tar"))
assertEquals(FileType.FB2, resolveFileTypeFromMetadata("book.fb2.zip", "application/zip"))
assertNull(resolveFileTypeFromMetadata("archive.zip", "application/zip"))
assertNull(resolveFileTypeFromMetadata("archive.tar", "application/x-tar"))
}
@Test
@ -56,6 +58,7 @@ class FileTypeResolverTest {
fun `plain txt remains txt when inner extension is unsupported`() {
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
assertEquals(FileType.PPTX, resolveFileTypeFromName("deck.pptx"))
assertEquals(FileType.CBT, resolveFileTypeFromName("comic.cbt"))
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
assertNull(resolveFileTypeFromName("archive.zip"))
}

View file

@ -8,11 +8,15 @@ import android.util.Log
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.credentials.CredentialManager
import androidx.lifecycle.ViewModel
import androidx.work.WorkManager
import com.aryan.reader.data.*
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.data.BookCacheDao
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
import com.aryan.reader.pdf.data.PdfMetaDao
import com.aryan.reader.pdf.data.PdfTextDao
import com.aryan.reader.pdf.data.PdfTextDatabase
import com.aryan.reader.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import io.mockk.*
@ -24,6 +28,7 @@ import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.*
import org.junit.After
import org.junit.AfterClass
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@ -34,7 +39,7 @@ import java.io.File
@OptIn(ExperimentalCoroutinesApi::class)
class MainViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var testDispatcher: TestDispatcher
private lateinit var viewModel: MainViewModel
private lateinit var mockApplication: Application
@ -50,8 +55,26 @@ class MainViewModelTest {
private val tagsFlow = MutableStateFlow<List<TagEntity>>(emptyList())
private val tagRefsFlow = MutableStateFlow<List<BookTagCrossRef>>(emptyList())
companion object {
@JvmStatic
@AfterClass
fun resetMainDispatcher() {
Dispatchers.resetMain()
}
}
private class TestMainViewModel(application: Application) : MainViewModel(application) {
fun clearForTest() {
ViewModel::class.java
.getDeclaredMethod("clear\$lifecycle_viewmodel_release")
.invoke(this)
}
}
@Before
fun setup() {
testDispatcher = StandardTestDispatcher()
recentFilesFlow.value = emptyList()
shelvesFlow.value = emptyList()
shelfRefsFlow.value = emptyList()
@ -100,6 +123,11 @@ class MainViewModelTest {
val mockBookCacheDb = mockk<BookCacheDatabase>(relaxed = true)
every { mockBookCacheDb.bookCacheDao() } returns mockk<BookCacheDao>(relaxed = true)
every { BookCacheDatabase.getDatabase(any()) } returns mockBookCacheDb
mockkObject(PdfTextDatabase.Companion)
val mockPdfTextDb = mockk<PdfTextDatabase>(relaxed = true)
every { mockPdfTextDb.pdfTextDao() } returns mockk<PdfTextDao>(relaxed = true)
every { mockPdfTextDb.pdfMetaDao() } returns mockk<PdfMetaDao>(relaxed = true)
every { PdfTextDatabase.getDatabase(any()) } returns mockPdfTextDb
mockkObject(WorkManager.Companion)
val mockWorkManager = mockk<WorkManager>(relaxed = true)
@ -114,6 +142,7 @@ class MainViewModelTest {
mockkConstructor(FeedbackRepository::class)
mockkConstructor(FontsRepository::class)
mockkConstructor(TtsController::class)
mockkConstructor(BookImporter::class)
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
every { anyConstructed<BillingClientWrapper>().initializeConnection() } just Runs
@ -126,6 +155,7 @@ class MainViewModelTest {
every { anyConstructed<BillingClientWrapper>().launchPurchaseFlow(any(), any(), any()) } just Runs
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
every { anyConstructed<FirestoreRepository>().removeListener(any()) } just Runs
every { anyConstructed<RemoteConfigRepository>().init() } just Runs
every { anyConstructed<TtsController>().ttsState } returns ttsStateFlow
every { anyConstructed<TtsController>().connect() } just Runs
@ -143,21 +173,30 @@ class MainViewModelTest {
coEvery { anyConstructed<RecentFilesRepository>().removeBooksFromShelf(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().addBooksToShelf(any(), any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteShelf(any()) } just Runs
coEvery { anyConstructed<RecentFilesRepository>().deleteFilePermanently(any()) } just Runs
coEvery { anyConstructed<BookImporter>().deleteBookByUriString(any()) } returns true
every { anyConstructed<FontsRepository>().getAllFonts() } returns customFontsFlow
coEvery { anyConstructed<FontsRepository>().deleteFont(any()) } just Runs
viewModel = MainViewModel(mockApplication)
viewModel = TestMainViewModel(mockApplication)
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
try {
if (::viewModel.isInitialized) {
testDispatcher.scheduler.advanceUntilIdle()
(viewModel as? TestMainViewModel)?.clearForTest()
testDispatcher.scheduler.advanceUntilIdle()
}
} finally {
unmockkAll()
}
}
@Test
fun `search query updates uiState when search is active`() = runTest {
fun `search query updates uiState when search is active`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -173,7 +212,7 @@ class MainViewModelTest {
}
@Test
fun `setSearchActive false clears the search query`() = runTest {
fun `setSearchActive false clears the search query`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -187,7 +226,7 @@ class MainViewModelTest {
}
@Test
fun `search query change is ignored while search is inactive`() = runTest {
fun `search query change is ignored while search is inactive`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -199,7 +238,7 @@ class MainViewModelTest {
}
@Test
fun `switching theme updates internal state and preferences`() = runTest {
fun `switching theme updates internal state and preferences`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -212,7 +251,7 @@ class MainViewModelTest {
}
@Test
fun `setAppFontPreference persists app font preference`() = runTest {
fun `setAppFontPreference persists app font preference`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -227,7 +266,7 @@ class MainViewModelTest {
}
@Test
fun `deleteFont resets matching app custom font preference`() = runTest {
fun `deleteFont resets matching app custom font preference`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -243,7 +282,59 @@ class MainViewModelTest {
}
@Test
fun `setTabsEnabled persists to shared preferences`() = runTest {
fun `deleteFonts deletes unique selected fonts and resets matching app custom font preference`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.setAppFontPreference(AppFontPreference.custom("font-b"))
viewModel.deleteFonts(listOf("font-a", "font-b", "font-a", ""))
advanceUntilIdle()
coVerify(exactly = 1) { anyConstructed<FontsRepository>().deleteFont("font-a") }
coVerify(exactly = 1) { anyConstructed<FontsRepository>().deleteFont("font-b") }
coVerify(exactly = 0) { anyConstructed<FontsRepository>().deleteFont("") }
assertEquals(AppFontPreference.System, viewModel.uiState.value.appFontPreference)
verify { mockEditor.putString("app_font_kind", AppFontPreferenceKind.SYSTEM.name) }
verify { mockEditor.remove("app_font_custom_id") }
}
@Test
fun `importFonts imports every selected font`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val firstUri = mockk<Uri>()
val secondUri = mockk<Uri>()
val firstFont = CustomFontEntity(
id = "font-1",
displayName = "First",
fileName = "font_1.ttf",
fileExtension = "ttf",
path = "/fonts/font_1.ttf",
timestamp = 1L
)
val secondFont = CustomFontEntity(
id = "font-2",
displayName = "Second",
fileName = "font_2.otf",
fileExtension = "otf",
path = "/fonts/font_2.otf",
timestamp = 2L
)
coEvery { anyConstructed<FontsRepository>().importFont(firstUri) } returns Result.success(firstFont)
coEvery { anyConstructed<FontsRepository>().importFont(secondUri) } returns Result.success(secondFont)
viewModel.importFonts(listOf(firstUri, secondUri))
advanceUntilIdle()
coVerify(exactly = 1) { anyConstructed<FontsRepository>().importFont(firstUri) }
coVerify(exactly = 1) { anyConstructed<FontsRepository>().importFont(secondUri) }
assertFalse(viewModel.uiState.value.isLoading)
}
@Test
fun `setTabsEnabled persists to shared preferences`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -256,7 +347,7 @@ class MainViewModelTest {
}
@Test
fun `setRenderMode persists mode without touching saved epub position`() = runTest {
fun `setRenderMode persists mode without touching saved epub position`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -272,7 +363,7 @@ class MainViewModelTest {
}
@Test
fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest {
fun `saveEpubReadingPosition forwards cfi locator and progress to repository`() = runTest(testDispatcher) {
val uriString = "content://books/one"
val uri = mockUri(uriString)
val locator = Locator(chapterIndex = 5, blockIndex = 77, charOffset = 14)
@ -301,7 +392,7 @@ class MainViewModelTest {
}
@Test
fun `setRecentFilesLimit persists and limits visible home recents`() = runTest {
fun `setRecentFilesLimit persists and limits visible home recents`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -318,7 +409,7 @@ class MainViewModelTest {
}
@Test
fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest {
fun `strict file filter pdf filename display and external file behavior persist preferences`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -339,7 +430,35 @@ class MainViewModelTest {
}
@Test
fun `screen capture protection persists and updates state`() = runTest {
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)
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 {
anyConstructed<RecentFilesRepository>().deleteFilePermanently(listOf("external-book"))
}
coVerify {
anyConstructed<BookImporter>().deleteBookByUriString(pendingUri)
}
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") }
} finally {
restored.clearForTest()
}
}
@Test
fun `screen capture protection persists and updates state`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -358,7 +477,7 @@ class MainViewModelTest {
}
@Test
fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest {
fun `setSortOrder persists preference and reorders visible home and library lists`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -380,7 +499,7 @@ class MainViewModelTest {
}
@Test
fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest {
fun `setMainScreenPage clamps to bottom navigation bounds and persists`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -393,7 +512,7 @@ class MainViewModelTest {
}
@Test
fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest {
fun `setLibraryScreenPage clamps to available library tabs and persists`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -407,7 +526,7 @@ class MainViewModelTest {
}
@Test
fun `create shelf dialog state opens and dismisses`() = runTest {
fun `create shelf dialog state opens and dismisses`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -422,7 +541,7 @@ class MainViewModelTest {
}
@Test
fun `selectAllRecentFiles toggles only visible recent home items`() = runTest {
fun `selectAllRecentFiles toggles only visible recent home items`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -445,7 +564,7 @@ class MainViewModelTest {
}
@Test
fun `selectAllLibraryFiles toggles all filtered library items`() = runTest {
fun `selectAllLibraryFiles toggles all filtered library items`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -465,7 +584,7 @@ class MainViewModelTest {
}
@Test
fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest {
fun `selectAllLibraryFiles clears selection when all visible library items are already selected`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -483,7 +602,7 @@ class MainViewModelTest {
}
@Test
fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest {
fun `togglePinForContextualItems pins selected home items and clears selection`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -503,7 +622,7 @@ class MainViewModelTest {
}
@Test
fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest {
fun `togglePinForContextualItems unpins when every selected item is already pinned`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -525,7 +644,7 @@ class MainViewModelTest {
}
@Test
fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest {
fun `clearContextualAction clears selected books without disturbing pinned state`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -544,7 +663,7 @@ class MainViewModelTest {
}
@Test
fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest {
fun `togglePinForContextualItems pins selected library items separately from home pins`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -564,7 +683,7 @@ class MainViewModelTest {
}
@Test
fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest {
fun `updateLibraryFilters updates state and persists every filter dimension`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -586,7 +705,7 @@ class MainViewModelTest {
}
@Test
fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest {
fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -607,16 +726,20 @@ class MainViewModelTest {
}
@Test
fun `saved library file filters drop stale unknown values during restore`() = runTest {
fun `saved library file filters drop stale unknown values during restore`() = runTest(testDispatcher) {
every { mockPrefs.getStringSet(KEY_FILTER_FILE_TYPES, any()) } returns mutableSetOf("PDF", "UNKNOWN")
val restored = MainViewModel(mockApplication)
assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes)
val restored = TestMainViewModel(mockApplication)
try {
assertEquals(setOf(FileType.PDF), restored.uiState.value.libraryFilters.fileTypes)
} finally {
restored.clearForTest()
testDispatcher.scheduler.advanceUntilIdle()
}
}
@Test
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest {
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -641,7 +764,7 @@ class MainViewModelTest {
}
@Test
fun `tag selection ignores empty targets and closes after opening`() = runTest {
fun `tag selection ignores empty targets and closes after opening`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -659,7 +782,7 @@ class MainViewModelTest {
}
@Test
fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest {
fun `toggleTagForBooks assigns and removes tags for sanitized book ids`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -679,7 +802,7 @@ class MainViewModelTest {
}
@Test
fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest {
fun `rename and delete shelf dialogs store their target and dismiss cleanly`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -701,7 +824,7 @@ class MainViewModelTest {
}
@Test
fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest {
fun `shelf selection only allows manual mutable shelves and toggles by click`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -723,7 +846,7 @@ class MainViewModelTest {
}
@Test
fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest {
fun `onShelfClick navigates when shelf contextual mode is inactive`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -740,7 +863,7 @@ class MainViewModelTest {
}
@Test
fun `shelf navigation sets library landing state and can be cleared`() = runTest {
fun `shelf navigation sets library landing state and can be cleared`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -759,7 +882,7 @@ class MainViewModelTest {
}
@Test
fun `clearShelfContextualAction clears selected shelves`() = runTest {
fun `clearShelfContextualAction clears selected shelves`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -776,7 +899,7 @@ class MainViewModelTest {
}
@Test
fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest {
fun `deleteSelectedShelves deletes only mutable selected shelves and clears selection`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -803,7 +926,7 @@ class MainViewModelTest {
}
@Test
fun `add books mode resets selection and tracks source changes`() = runTest {
fun `add books mode resets selection and tracks source changes`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -837,7 +960,7 @@ class MainViewModelTest {
}
@Test
fun `toggleBookSelectionForAdding toggles individual books`() = runTest {
fun `toggleBookSelectionForAdding toggles individual books`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -852,7 +975,7 @@ class MainViewModelTest {
}
@Test
fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest {
fun `addBooksToShelf saves selected books for mutable shelves and exits add mode`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -876,7 +999,7 @@ class MainViewModelTest {
}
@Test
fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest {
fun `addBooksToShelf dismisses add mode when target shelf is not mutable`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -893,7 +1016,7 @@ class MainViewModelTest {
}
@Test
fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest {
fun `removeContextualItemsFromShelf removes selected books from the current mutable shelf`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -915,7 +1038,7 @@ class MainViewModelTest {
}
@Test
fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest {
fun `app appearance settings persist contrast brightness seed and custom themes`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -951,7 +1074,7 @@ class MainViewModelTest {
}
@Test
fun `setAppSeedColor can clear a selected seed color`() = runTest {
fun `setAppSeedColor can clear a selected seed color`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -968,7 +1091,7 @@ class MainViewModelTest {
}
@Test
fun `addCustomAppTheme replaces existing theme with the same id`() = runTest {
fun `addCustomAppTheme replaces existing theme with the same id`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -985,7 +1108,7 @@ class MainViewModelTest {
}
@Test
fun `banner message logic works correctly`() = runTest {
fun `banner message logic works correctly`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
@ -1004,7 +1127,7 @@ class MainViewModelTest {
}
@Test
fun `persistent banner is not auto dismissed`() = runTest {
fun `persistent banner is not auto dismissed`() = runTest(testDispatcher) {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}

View file

@ -12,7 +12,17 @@ class ReaderBrightnessSettingsTest {
assertTrue(defaults.useSystemBrightness)
assertEquals(0.75f, defaults.safeCustomBrightness, 0.0001f)
assertEquals(0.05f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f)
assertEquals(0.01f, defaults.copy(customBrightness = 0f).safeCustomBrightness, 0.0001f)
assertEquals(0.02f, defaults.copy(customBrightness = 0.02f).safeCustomBrightness, 0.0001f)
assertEquals(0.23f, defaults.copy(customBrightness = 0.234f).safeCustomBrightness, 0.0001f)
assertEquals(1f, defaults.copy(customBrightness = 2f).safeCustomBrightness, 0.0001f)
}
@Test
fun `brightness step controls move by one percent and clamp`() {
assertEquals(0.74f, stepReaderBrightness(0.75f, -1), 0.0001f)
assertEquals(0.76f, stepReaderBrightness(0.75f, 1), 0.0001f)
assertEquals(0.01f, stepReaderBrightness(0.01f, -1), 0.0001f)
assertEquals(1f, stepReaderBrightness(1f, 1), 0.0001f)
}
}

View file

@ -80,6 +80,68 @@ class ReaderSliderChromeStateTest {
)
}
@Test
fun `one based slider stepping clamps to epub page range`() {
assertEquals(
1,
readerSliderStepPage(
currentPage = 1,
delta = -1,
minPage = 1,
maxPage = 20
)
)
assertEquals(
11,
readerSliderStepPage(
currentPage = 10,
delta = 1,
minPage = 1,
maxPage = 20
)
)
assertEquals(
20,
readerSliderStepPage(
currentPage = 20,
delta = 1,
minPage = 1,
maxPage = 20
)
)
}
@Test
fun `zero based slider stepping clamps to pdf display page range`() {
assertEquals(
0,
readerSliderStepPage(
currentPage = 0,
delta = -1,
minPage = 0,
maxPage = 9
)
)
assertEquals(
6,
readerSliderStepPage(
currentPage = 5,
delta = 1,
minPage = 0,
maxPage = 9
)
)
assertEquals(
9,
readerSliderStepPage(
currentPage = 9,
delta = 1,
minPage = 0,
maxPage = 9
)
)
}
@Test
fun `slider content color falls back on light page when theme text is low contrast`() {
val colors = readerSliderChromeColors(

View file

@ -3,6 +3,8 @@ 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.data.toBookMetadata
import com.aryan.reader.data.toRecentFileItem
import com.aryan.reader.shared.ReaderFeatureSurface
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.SharedReaderScreenState
@ -46,6 +48,48 @@ class SharedModelMappersTest {
assertEquals(listOf(tag), mapped.tags)
}
@Test
fun `book mapper carries android epub block locator through shared model`() {
val original = recentFile(
id = "book",
type = FileType.EPUB,
lastChapterIndex = 3,
lastPage = 18,
lastPositionCfi = "android-locator:3:44:120",
locatorBlockIndex = 44,
locatorCharOffset = 120
)
val shared = original.toSharedBookItem()
val mapped = shared.toRecentFileItem()
assertEquals(3, shared.readerPosition?.chapterIndex)
assertEquals(18, shared.readerPosition?.pageIndex)
assertEquals(44, shared.readerPosition?.blockIndex)
assertEquals(120, shared.readerPosition?.charOffset)
assertEquals(original.lastChapterIndex, mapped.lastChapterIndex)
assertEquals(original.lastPage, mapped.lastPage)
assertEquals(original.lastPositionCfi, mapped.lastPositionCfi)
assertEquals(original.locatorBlockIndex, mapped.locatorBlockIndex)
assertEquals(original.locatorCharOffset, mapped.locatorCharOffset)
}
@Test
fun `android cloud metadata preserves file content timestamp`() {
val original = recentFile(
id = "book",
type = FileType.EPUB,
fileContentModifiedTimestamp = 1_500L
)
val metadata = original.toBookMetadata()
val restored = metadata.toRecentFileItem()
assertEquals(1_500L, metadata.fileContentModifiedTimestamp)
assertEquals(1_500L, restored.fileContentModifiedTimestamp)
assertFalse(restored.isAvailable)
}
@Test
fun `shared projection state maps shelves tabs selections and tags back to android state`() {
val tag = TagEntity(id = "tag", name = "Queued", createdAt = 1L)
@ -92,6 +136,41 @@ class SharedModelMappersTest {
assertEquals(listOf(tag), android.allTags)
}
@Test
fun `shared projection state reuses mapped android book instances by id`() {
val book = recentFile("book")
val sharedBook = book.toSharedBookItem()
val sharedShelf = SharedShelf(
id = "manual",
name = "Manual",
type = SharedShelfType.MANUAL,
books = listOf(sharedBook),
directBooks = listOf(sharedBook)
)
val projected = SharedReaderScreenState(
recentBooks = listOf(sharedBook),
libraryBooks = listOf(sharedBook),
rawLibraryBooks = listOf(sharedBook),
shelves = listOf(sharedShelf),
openTabs = listOf(sharedBook),
booksAvailableForAdding = listOf(sharedBook)
)
val android = projected.toAndroidReaderScreenState(
base = ReaderScreenState(),
androidBooksById = mapOf(book.bookId to book)
)
val mappedBook = android.rawLibraryFiles.single()
assertSame(book, mappedBook)
assertSame(mappedBook, android.recentFiles.single())
assertSame(mappedBook, android.allRecentFiles.single())
assertSame(mappedBook, android.shelves.single().books.single())
assertSame(mappedBook, android.shelves.single().directBooks.single())
assertSame(mappedBook, android.openTabs.single())
assertSame(mappedBook, android.booksAvailableForAdding.single())
}
@Test
fun `enum filter and folder mappers round trip between android and shared`() {
val filters = LibraryFilters(
@ -115,6 +194,7 @@ class SharedModelMappersTest {
assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters())
assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder())
assertTrue(FileType.PPTX in PDF_VIEWER_FILE_TYPES)
assertTrue(FileType.CBT 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)
@ -133,6 +213,18 @@ class SharedModelMappersTest {
assertEquals(listOf(tag), tagged.single().tags)
}
@Test
fun `tag resolver reuses book item when resolved tags are unchanged`() {
val file = recentFile("book")
val resolved = listOf(file).withResolvedTags(
dbTags = emptyList(),
tagRefs = emptyList()
)
assertSame(file, resolved.single())
}
private fun recentFile(
id: String,
type: FileType = FileType.EPUB,
@ -141,6 +233,12 @@ class SharedModelMappersTest {
isAvailable: Boolean = true,
bookmarksJson: String? = null,
sourceFolderUri: String? = null,
lastChapterIndex: Int? = null,
lastPage: Int? = null,
lastPositionCfi: String? = null,
locatorBlockIndex: Int? = null,
locatorCharOffset: Int? = null,
fileContentModifiedTimestamp: Long = 0L,
tags: List<TagEntity> = emptyList()
) = RecentFileItem(
bookId = id,
@ -152,6 +250,12 @@ class SharedModelMappersTest {
bookmarksJson = bookmarksJson,
sourceFolderUri = sourceFolderUri,
customName = customName,
lastChapterIndex = lastChapterIndex,
lastPage = lastPage,
lastPositionCfi = lastPositionCfi,
locatorBlockIndex = locatorBlockIndex,
locatorCharOffset = locatorCharOffset,
fileContentModifiedTimestamp = fileContentModifiedTimestamp,
tags = tags
)

View file

@ -0,0 +1,69 @@
package com.aryan.reader
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SyncedFolderPrefsTest {
@Test
fun `missing local sync flag defaults enabled`() {
val folders = SyncedFolderPrefs.decodeSyncedFolders(
jsonString = """
[
{
"uri": "content://folder",
"name": "Books",
"lastScanTime": 12,
"allowedFileTypes": ["PDF"]
}
]
""".trimIndent(),
legacyUri = null,
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
)
assertTrue(folders.single().localSyncEnabled)
assertTrue(
SyncedFolderPrefs.isLocalSyncEnabled(
jsonString = SyncedFolderPrefs.encodeSyncedFolders(
folders,
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
),
legacyUri = null,
folderUriString = "content://folder",
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
)
)
}
@Test
fun `disabled local sync flag persists and is checked`() {
val encoded = SyncedFolderPrefs.encodeSyncedFolders(
listOf(
SyncedFolder(
uriString = "content://folder",
name = "Books",
lastScanTime = 12L,
allowedFileTypes = setOf(FileType.PDF),
localSyncEnabled = false
)
),
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
)
val decoded = SyncedFolderPrefs.decodeSyncedFolders(
jsonString = encoded,
legacyUri = null,
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
)
assertFalse(decoded.single().localSyncEnabled)
assertFalse(
SyncedFolderPrefs.isLocalSyncEnabled(
jsonString = encoded,
legacyUri = null,
folderUriString = "content://folder",
syncableTypes = setOf(FileType.PDF, FileType.EPUB)
)
)
}
}

View file

@ -56,6 +56,7 @@ class RecentFileDaoReadingPositionTest {
assertEquals(58.5f, saved.progressPercentage)
assertEquals(9_000L, saved.timestamp)
assertEquals(9_000L, saved.lastModifiedTimestamp)
assertEquals(9_000L, saved.readingPositionModifiedTimestamp)
}
@Test
@ -100,6 +101,7 @@ class RecentFileDaoReadingPositionTest {
assertEquals(21, item.locatorBlockIndex)
assertEquals(12, item.locatorCharOffset)
assertEquals(44f, item.progressPercentage)
assertEquals(3_000L, item.readingPositionModifiedTimestamp)
assertTrue(item.isRecent)
}

View file

@ -18,6 +18,7 @@ class RecentFileItemReadingPositionMappingTest {
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
assertEquals(item.readingPositionModifiedTimestamp, roundTripped.readingPositionModifiedTimestamp)
}
@Test
@ -32,6 +33,7 @@ class RecentFileItemReadingPositionMappingTest {
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
assertEquals(item.readingPositionModifiedTimestamp, roundTripped.readingPositionModifiedTimestamp)
}
@Test
@ -101,6 +103,7 @@ class RecentFileItemReadingPositionMappingTest {
locatorCharOffset = 88,
progressPercentage = 61.5f,
lastModifiedTimestamp = 2_000L,
readingPositionModifiedTimestamp = 1_900L,
fileContentModifiedTimestamp = 3_000L,
bookmarksJson = """[{"cfi":"/4/2"}]""",
highlightsJson = """[{"cfi":"/4/2/6:88"}]"""

View file

@ -104,6 +104,8 @@ class RecentFilesRepositoryReadingPositionMergeTest {
locatorBlockIndex = 31,
locatorCharOffset = 12,
progressPercentage = 82f,
lastModifiedTimestamp = 2_000L,
readingPositionModifiedTimestamp = 2_000L,
isRecent = true
)
)
@ -113,6 +115,41 @@ class RecentFilesRepositoryReadingPositionMergeTest {
assertEquals(31, inserted.captured.locatorBlockIndex)
assertEquals(12, inserted.captured.locatorCharOffset)
assertEquals(82f, inserted.captured.progressPercentage)
assertEquals(2_000L, inserted.captured.readingPositionModifiedTimestamp)
}
@Test
fun `addRecentFile preserves newer existing reading position when incoming metadata timestamp is newer`() = runTest {
val inserted = slot<RecentFileEntity>()
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity().copy(
readingPositionModifiedTimestamp = 1_800L
)
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
repository.addRecentFile(
RecentFileItem(
bookId = "book-1",
uriString = "content://new",
type = FileType.EPUB,
displayName = "New.epub",
timestamp = 3_000L,
lastChapterIndex = 1,
lastPositionCfi = "/old/remote",
locatorBlockIndex = 2,
locatorCharOffset = 3,
progressPercentage = 12f,
lastModifiedTimestamp = 3_000L,
readingPositionModifiedTimestamp = 1_200L,
isRecent = true
)
)
assertEquals("/4/2/6:44", inserted.captured.lastPositionCfi)
assertEquals(6, inserted.captured.lastChapterIndex)
assertEquals(24, inserted.captured.locatorBlockIndex)
assertEquals(44, inserted.captured.locatorCharOffset)
assertEquals(71.5f, inserted.captured.progressPercentage)
assertEquals(1_800L, inserted.captured.readingPositionModifiedTimestamp)
}
@Test

View file

@ -0,0 +1,132 @@
package com.aryan.reader.epub
import android.content.Context
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.Base64
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@RunWith(RobolectricTestRunner::class)
class EpubImportSecurityTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `safeFileInRoot rejects traversal outside extraction root`() {
val root = temp.newFolder("root")
assertNotNull(safeFileInRoot(root, "OEBPS/image.png"))
assertTrue(safeFileInRoot(root, "../outside.txt") == null)
}
@Test
fun `xml parser rejects doctypes from untrusted book metadata`() {
val xml = """
<!DOCTYPE root [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
<root>&xxe;</root>
""".trimIndent()
assertThrows(Exception::class.java) {
parseXMLFile(ByteArrayInputStream(xml.toByteArray(Charsets.UTF_8)))
}
}
@Test
fun `odt parser skips zip entries that escape extraction root`() = runTest {
val extractionDir = temp.newFolder("odt-root")
val outside = File(extractionDir.parentFile, "odt-evil.txt")
val parser = OdtParser(contextWithCache(temp.newFolder("odt-cache")))
parser.createOdtBook(
inputStream = ByteArrayInputStream(
zipBytes(
"content.xml" to minimalOdtContent().toByteArray(Charsets.UTF_8),
"../odt-evil.txt" to "evil".toByteArray(Charsets.UTF_8)
)
),
bookId = "odt-book",
originalBookNameHint = "book.odt",
isFlat = false,
parseContent = false,
extractionDirOverride = extractionDir
)
assertFalse(outside.exists())
}
@Test
fun `fb2 parser sanitizes binary image ids before writing files`() = runTest {
val extractionDir = temp.newFolder("fb2-root")
val outside = File(extractionDir.parentFile, "fb2-evil.png")
val parser = Fb2Parser(contextWithCache(temp.newFolder("fb2-cache")))
parser.createFb2Book(
inputStream = ByteArrayInputStream(minimalFb2WithUnsafeImage().toByteArray(Charsets.UTF_8)),
bookId = "fb2-book",
originalBookNameHint = "book.fb2",
parseContent = true,
extractionDirOverride = extractionDir
)
assertFalse(outside.exists())
assertTrue(extractionDir.listFiles().orEmpty().any { it.name.startsWith("fb2-evil_") && it.extension == "png" })
}
private fun contextWithCache(cacheDir: File): Context {
val context = mockk<Context>(relaxed = true)
every { context.cacheDir } returns cacheDir
return context
}
private fun zipBytes(vararg entries: Pair<String, ByteArray>): ByteArray {
val output = ByteArrayOutputStream()
ZipOutputStream(output).use { zip ->
entries.forEach { (name, bytes) ->
zip.putNextEntry(ZipEntry(name))
zip.write(bytes)
zip.closeEntry()
}
}
return output.toByteArray()
}
private fun minimalOdtContent(): String {
return """
<office:document-content
xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0"
xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0">
<office:body><office:text><text:p>Hello</text:p></office:text></office:body>
</office:document-content>
""".trimIndent()
}
private fun minimalFb2WithUnsafeImage(): String {
val payload = Base64.getEncoder().encodeToString(byteArrayOf(1, 2, 3, 4))
return """
<FictionBook xmlns:l="http://www.w3.org/1999/xlink">
<description><title-info><book-title>Unsafe image</book-title></title-info></description>
<body>
<section>
<p>Hello</p>
<image l:href="#../fb2-evil.png"/>
</section>
</body>
<binary id="../fb2-evil.png" content-type="image/png">$payload</binary>
</FictionBook>
""".trimIndent()
}
}

View file

@ -56,7 +56,11 @@ class SingleFileImporterTest {
assertEquals("Part 1", book.chapters.single().title)
assertTrue(book.chapters.single().plainTextContent.contains("First <line> continues"))
assertTrue(File(book.extractionBasePath, "part_1.html").readText().contains("First &lt;line&gt;"))
assertTrue(File(book.extractionBasePath, "book_metadata.json").isFile)
val metadata = File(book.extractionBasePath, "book_metadata.json")
assertTrue(metadata.isFile)
val metadataText = metadata.readText()
assertFalse(metadataText.contains("First <line> continues"))
assertTrue(metadataText.contains("plainTextLength"))
}
@Test
@ -79,8 +83,29 @@ class SingleFileImporterTest {
)
assertEquals(first.title, second.title)
assertEquals(first.chapters.single().plainTextContent, second.chapters.single().plainTextContent)
assertTrue(second.chapters.single().plainTextContent.contains("Cached content"))
assertEquals(first.chapters.single().plainTextLength, second.chapters.single().plainTextLength)
assertEquals("", second.chapters.single().plainTextContent)
assertTrue(File(second.extractionBasePath, second.chapters.single().htmlFilePath).readText().contains("Cached content"))
}
@Test
fun `plain text import ignores oversized legacy cached metadata before reading it`() = runTest {
val cache = temp.newFolder("txt-cache-oversized")
val context = contextWithCache(cache)
val bookId = "oversized-cache-book"
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
File(extractionDir, "book_metadata.json").writeText("x".repeat((2L * 1024L * 1024L + 1L).toInt()))
val importer = SingleFileImporter(context)
val book = importer.importSingleFile(
inputStream = ByteArrayInputStream("Fresh content after oversized cache".toByteArray()),
type = FileType.TXT,
originalBookNameHint = "Fresh.txt",
bookId = bookId
)
assertEquals("Fresh", book.title)
assertTrue(book.chapters.single().plainTextContent.contains("Fresh content"))
}
@Test

View file

@ -0,0 +1,38 @@
package com.aryan.reader.epubreader
import com.aryan.reader.shared.ReaderLocator
import org.json.JSONArray
import org.junit.Assert.assertEquals
import org.junit.Test
class ChapterWebViewHighlightJsonTest {
@Test
fun `webview highlight json keeps shared locator offsets`() {
val highlight = UserHighlight(
id = "highlight-1",
cfi = "desktop:6:120:145",
text = "synced desktop text",
color = HighlightColor.GREEN,
chapterIndex = 6,
locator = ReaderLocator(
chapterIndex = 6,
pageIndex = 2,
startOffset = 120,
endOffset = 145,
textQuote = "synced desktop text",
cfi = "desktop:6:120:145"
)
)
val obj = JSONArray(highlightsJsonForWebView(listOf(highlight))).getJSONObject(0)
val locator = obj.getJSONObject("locator")
assertEquals("desktop:6:120:145", obj.getString("cfi"))
assertEquals("user-highlight-green", obj.getString("cssClass"))
assertEquals(6, locator.getInt("chapterIndex"))
assertEquals(120, locator.getInt("startOffset"))
assertEquals(145, locator.getInt("endOffset"))
assertEquals("synced desktop text", locator.getString("textQuote"))
}
}

View file

@ -257,7 +257,8 @@ class EpubReaderBridgeAndControlsTest {
val sections = epubOverflowMenuSections(
hiddenTools = setOf(
ReaderTool.TTS_SETTINGS.name,
ReaderTool.TTS_REPLACEMENTS.name
ReaderTool.TTS_REPLACEMENTS.name,
ReaderTool.BOOK_REPLACEMENTS.name
),
hasHiddenToolbarTools = false,
hasToggleReflow = false,
@ -267,6 +268,21 @@ class EpubReaderBridgeAndControlsTest {
assertEquals(EpubOverflowMenuSection.AUTO_SCROLL, sections.last())
assertTrue(EpubOverflowMenuSection.TTS_SETTINGS !in sections)
assertTrue(EpubOverflowMenuSection.BOOK_REPLACEMENTS !in sections)
}
@Test
fun `epub overflow sections expose book replacements when visible`() {
val sections = epubOverflowMenuSections(
hiddenTools = emptySet(),
hasHiddenToolbarTools = false,
hasToggleReflow = false,
hasDeleteReflow = false,
hasFileInfo = false
)
assertTrue(EpubOverflowMenuSection.BOOK_REPLACEMENTS in sections)
assertTrue(sections.indexOf(EpubOverflowMenuSection.BOOK_REPLACEMENTS) < sections.indexOf(EpubOverflowMenuSection.TTS_SETTINGS))
}
@Test

View file

@ -40,6 +40,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
assertEquals(ReaderFont.ORIGINAL, format.font)
assertEquals(ReaderTextAlign.DEFAULT, format.textAlign)
assertNull(format.customPath)
assertFalse(loadNativeVerticalRenderer(context))
}
@Test
@ -136,6 +137,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
saveVolumeScrollSetting(context, true)
saveRemoveEdgePadding(context, true)
saveFormatIsLocal(context, "book", true)
saveNativeVerticalRenderer(context, true)
assertEquals(1.35f, loadTtsSpeechRate(context), 0.0001f)
assertEquals(0.85f, loadTtsPitch(context), 0.0001f)
@ -149,6 +151,7 @@ class EpubReaderPreferencesAndAnnotationsTest {
assertTrue(loadVolumeScrollSetting(context))
assertTrue(loadRemoveEdgePadding(context))
assertTrue(loadFormatIsLocal(context, "book"))
assertTrue(loadNativeVerticalRenderer(context))
assertEquals(0f, loadHorizontalMargin(context), 0.0001f)
}

View file

@ -70,6 +70,24 @@ class EpubReaderSearchTest {
assertEquals("Chunky", result.locationTitle)
}
@Test
fun `search scans oversized text nodes in bounded windows`() = runTest {
val root = temp.newFolder("bounded-window")
val filler = "alpha ".repeat(7_000)
writeChapter(
root,
"chapter.xhtml",
"<html><body><p>${filler}Needle ${filler}pineedle ${filler}Needle</p></body></html>"
)
val book = epubBook(root, listOf(chapter("ch1", "Large", "chapter.xhtml")))
val results = createEpubSearcher(book)("needle")
assertEquals(2, results.size)
assertEquals(listOf(0, 1), results.map { it.occurrenceIndexInLocation })
assertTrue(results.all { it.snippet.text.contains("Needle", ignoreCase = true) })
}
@Test
fun `search currently requires only a word start and highlights the matched substring`() = runTest {
val root = temp.newFolder("word-start")

View file

@ -0,0 +1,47 @@
package com.aryan.reader.epubreader
import com.aryan.reader.shared.PageInfoMode
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class EpubReaderVisualOptionsStateTest {
@Test
fun `page info always show remains visible independent of reader chrome`() {
assertTrue(
shouldShowEpubPageInfoBar(
pageInfoMode = PageInfoMode.DEFAULT,
showReaderChrome = false
)
)
assertTrue(
shouldShowEpubPageInfoBar(
pageInfoMode = PageInfoMode.DEFAULT,
showReaderChrome = true
)
)
}
@Test
fun `page info sync follows reader chrome and hidden never shows`() {
assertFalse(
shouldShowEpubPageInfoBar(
pageInfoMode = PageInfoMode.SYNC,
showReaderChrome = false
)
)
assertTrue(
shouldShowEpubPageInfoBar(
pageInfoMode = PageInfoMode.SYNC,
showReaderChrome = true
)
)
assertFalse(
shouldShowEpubPageInfoBar(
pageInfoMode = PageInfoMode.HIDDEN,
showReaderChrome = true
)
)
}
}

View file

@ -47,4 +47,19 @@ class EpubTtsChunkMatchingTest {
)
)
}
@Test
fun `chunk start matching accepts target offset inside matching source block`() {
val chunks = listOf(
TtsChunk("Alpha beta gamma", "/4/8/2", 10),
TtsChunk("Delta epsilon", "/4/10/2", 0)
)
val nativeVerticalTarget = TtsChunk(
text = "",
sourceCfi = "/4/8",
startOffsetInSource = 16
)
assertEquals(0, findTtsChunkStartIndex(chunks, nativeVerticalTarget))
}
}

View file

@ -193,7 +193,8 @@ class OpdsParserTest {
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
),
OpdsAcquisition("epub", "application/epub+zip"),
OpdsAcquisition("unknown", "application/octet-stream")
OpdsAcquisition("unknown", "application/octet-stream"),
OpdsAcquisition("cbt", "application/vnd.comicbook+tar")
)
val entry = OpdsEntry(
id = "id",
@ -208,6 +209,7 @@ class OpdsParserTest {
assertEquals("PPTX", acquisitions[2].formatName)
assertEquals("TXT", acquisitions[0].formatName)
assertEquals("OCTET-STREAM", acquisitions[4].formatName)
assertEquals("CBT", acquisitions[5].formatName)
assertEquals(acquisitions[3], entry.bestAcquisition)
}
}

View file

@ -102,6 +102,24 @@ class OpdsRepositoryTest {
assertNotNull(Regex("""response="[a-f0-9]{32}"""").find(header))
}
@Test
fun `digest authenticator selects auth from qop list`() {
val request = Request.Builder()
.url("https://example.org/catalog/feed")
.build()
val response = responseFor(
request,
"Digest realm=\"realm\", nonce=\"abc\", qop=\"auth,auth-int\""
)
val authenticated = OpdsRepository.OpdsAuthenticator("user", "pass")
.authenticate(null, response)
val header = authenticated?.header("Authorization").orEmpty()
assertTrue(header.contains("qop=auth"))
assertTrue(!header.contains("auth,auth-int"))
}
@Test
fun `authenticator ignores unsupported challenge`() {
val request = Request.Builder().url("https://example.org/feed").build()

View file

@ -0,0 +1,45 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class AndroidHtmlResourceResolverTest {
@get:Rule
val temp = TemporaryFolder()
@Test
fun `resolvePath returns files inside extraction root`() {
val root = temp.newFolder("book")
val image = File(root, "OEBPS/images/picture.png").apply {
parentFile?.mkdirs()
writeText("image")
}
assertEquals(
image.canonicalPath,
AndroidHtmlResourceResolver.resolvePath(
chapterAbsPath = "OEBPS/chapter.xhtml",
extractionBasePath = root.absolutePath,
src = "images/picture.png"
)
)
}
@Test
fun `resolvePath rejects paths that escape extraction root`() {
val root = temp.newFolder("book")
File(root.parentFile, "outside.png").writeText("outside")
assertNull(
AndroidHtmlResourceResolver.resolvePath(
chapterAbsPath = "OEBPS/chapter.xhtml",
extractionBasePath = root.absolutePath,
src = "../../outside.png"
)
)
}
}

View file

@ -39,6 +39,7 @@ class ContentStylerTest {
).single() as ParagraphBlock
assertEquals(TextAlign.Justify, block.textAlign)
assertEquals(TextAlign.Justify, block.content.paragraphStyles.first().item.textAlign)
assertEquals("p1", block.elementId)
assertEquals("/4/2", block.cfi)
assertEquals(7, block.startCharOffsetInSource)
@ -46,6 +47,22 @@ class ContentStylerTest {
assertEquals("Aligned text", block.content.text)
}
@Test
fun `paragraph styling downgrades css justify unless user explicitly forces alignment`() {
val block = styler(userTextAlign = null).style(
listOf(
paragraph(
text = "Justified text",
blockIndex = 20,
style = CssStyle(paragraphStyle = androidx.compose.ui.text.ParagraphStyle(textAlign = TextAlign.Justify))
)
)
).single() as ParagraphBlock
assertEquals(TextAlign.Left, block.textAlign)
assertEquals(TextAlign.Left, block.content.paragraphStyles.first().item.textAlign)
}
@Test
fun `floating image is grouped with following paragraphs until clear`() {
val blocks = styler().style(
@ -136,6 +153,52 @@ class ContentStylerTest {
})
}
@Test
fun `link styling is applied after nested epub span styling`() {
val label = "Nested link"
val paragraph = SemanticParagraph(
text = label,
spans = listOf(
SemanticSpan(
start = 0,
end = label.length,
style = CssStyle(),
linkHref = "https://example.org",
tag = "a"
),
SemanticSpan(
start = 0,
end = label.length,
style = CssStyle(
spanStyle = SpanStyle(
color = Color.Red,
background = Color.Yellow,
textDecoration = TextDecoration.None
)
),
tag = "span"
)
),
style = CssStyle(),
elementId = null,
cfi = "/4/2",
blockIndex = 21
)
val styled = styler().style(listOf(paragraph)).single() as ParagraphBlock
val finalCoveringStyle = styled.content.spanStyles
.filter { it.start <= 0 && it.end >= label.length }
.last()
.item
assertEquals("https://example.org", styled.content.getStringAnnotations("URL", 0, label.length).single().item)
assertTrue(finalCoveringStyle.color.isSpecified)
assertTrue(finalCoveringStyle.color != Color.Red)
assertTrue(finalCoveringStyle.background.isSpecified)
assertTrue(finalCoveringStyle.background != Color.Yellow)
assertTrue(finalCoveringStyle.textDecoration?.contains(TextDecoration.Underline) == true)
}
@Test
fun `runtime theme reapplies visible link style for cached paginated text`() {
val linkText = "Cached link"
@ -166,6 +229,30 @@ class ContentStylerTest {
range.item.color != Color(0xFFE0E0E0) &&
range.item.background.isSpecified &&
range.item.textDecoration?.contains(TextDecoration.Underline) == true
})
}
@Test
fun `block anchor from html is styled and annotated as paginated link`() {
val semanticBlocks = htmlToSemanticBlocks(
html = """<html><body><a href="chapter2.xhtml#start"><p>Continue reading</p></a></body></html>""",
cssRules = OptimizedCssRules(),
textStyle = TextStyle(fontSize = 16.sp, color = Color.Black),
chapterAbsPath = "OEBPS/chapter1.xhtml",
extractionBasePath = "",
density = Density(1f),
fontFamilyMap = emptyMap(),
constraints = androidx.compose.ui.unit.Constraints(maxWidth = 400, maxHeight = 800)
)
val paragraph = styler().style(semanticBlocks).single() as ParagraphBlock
assertEquals("chapter2.xhtml#start", paragraph.content.getStringAnnotations("URL", 0, paragraph.content.length).single().item)
assertTrue(paragraph.content.spanStyles.any { range ->
range.start == 0 &&
range.end == paragraph.content.length &&
range.item.background.isSpecified &&
range.item.textDecoration?.contains(TextDecoration.Underline) == true
})
}

View file

@ -22,6 +22,8 @@ import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.File
import java.nio.file.Files
@OptIn(ExperimentalSerializationApi::class)
class LocatorConverterTest {
@ -41,6 +43,31 @@ class LocatorConverterTest {
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 13), locator)
}
@Test
fun `cfi local offsets become absolute locators and serialize back locally`() = runTest {
val converter = converterFor(
listOf(paragraph("Offset paragraph", blockIndex = 2, cfi = "/4/2/6", offset = 100))
)
val book = book()
val locator = converter.getLocatorFromCfi(book, chapterIndex = 0, cfi = "/4/2/6:7")
val cfi = locator?.let { converter.getCfiFromLocator(book, it) }
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 107), locator)
assertEquals("/4/2/6:7", cfi)
}
@Test
fun `multipart cfi uses first point local offset when resolving locator`() = runTest {
val converter = converterFor(
listOf(paragraph("Offset paragraph", blockIndex = 2, cfi = "/4/2/6", offset = 100))
)
val locator = converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2/6:7|/4/2/6:12")
assertEquals(Locator(chapterIndex = 0, blockIndex = 2, charOffset = 107), locator)
}
@Test
fun `zero estimate semantic cache remains usable`() = runTest {
val converter = converterFor(semanticBlocks(), estimatedPageCount = 0)
@ -170,6 +197,27 @@ class LocatorConverterTest {
assertNull(converter.getLocatorFromCfi(book(), chapterIndex = 0, cfi = "/4/2"))
}
@Test
fun `large uncached chapter file is skipped instead of parsed on demand`() = runTest {
val tempDir = Files.createTempDirectory("large-locator-chapter").toFile()
try {
File(tempDir, "c1.xhtml").writeText("<html><body>${"x".repeat(2_200_000)}</body></html>")
val dao = FakeBookCacheDao(null)
val converter = LocatorConverter(dao, proto, mockk<Context>(relaxed = true))
val locator = converter.getLocatorFromCfi(
book = book(extractionBasePath = tempDir.absolutePath),
chapterIndex = 0,
cfi = "/4/2"
)
assertNull(locator)
assertTrue(dao.insertedChapters.isEmpty())
} finally {
tempDir.deleteRecursively()
}
}
private fun converterFor(blocks: List<SemanticBlock>, estimatedPageCount: Int = 1): LocatorConverter {
val chapter = ProcessedChapter(
bookId = "Book",
@ -211,7 +259,7 @@ class LocatorConverterTest {
)
}
private fun book(): EpubBook {
private fun book(extractionBasePath: String = ""): EpubBook {
return EpubBook(
fileName = "book.epub",
title = "Book",
@ -228,7 +276,7 @@ class LocatorConverterTest {
htmlContent = ""
)
),
extractionBasePath = ""
extractionBasePath = extractionBasePath
)
}
@ -236,12 +284,15 @@ class LocatorConverterTest {
private val chapter: ProcessedChapter?
) : BookCacheDao() {
val requestedBookIds = mutableListOf<String>()
val insertedChapters = mutableListOf<ProcessedChapter>()
override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int): ProcessedChapter? {
override suspend fun getProcessedChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int?): ProcessedChapter? {
requestedBookIds += bookId
return chapter
}
override suspend fun insertProcessedChapters(chapters: List<ProcessedChapter>) = Unit
override suspend fun insertProcessedChapters(chapters: List<ProcessedChapter>) {
insertedChapters += chapters
}
override suspend fun getProcessedBook(bookId: String): ProcessedBook? = null
override suspend fun insertProcessedBook(book: ProcessedBook) = Unit
@ -260,11 +311,13 @@ class LocatorConverterTest {
override suspend fun getPageIndexEntries(bookId: String, configHash: Int, chapterIndex: Int): List<PageIndexEntry> = emptyList()
override suspend fun cleanupOldPageCaches(bookId: String) = Unit
protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null
protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int): List<ByteArray> = emptyList()
protected override suspend fun getChapterMetadata(bookId: String, chapterIndex: Int, styleConfigHash: Int): ProcessedChapterMetadata? = null
protected override suspend fun getAnyChapterMetadata(bookId: String, chapterIndex: Int): ProcessedChapterMetadata? = null
protected override suspend fun getChapterChunks(bookId: String, chapterIndex: Int, styleConfigHash: Int): List<ByteArray> = emptyList()
protected override suspend fun insertChapterMetadata(metadata: ProcessedChapterMetadata) = Unit
protected override suspend fun insertChapterChunks(chunks: List<ProcessedChapterChunk>) = Unit
protected override suspend fun deleteChapterMetadataForBook(bookId: String) = Unit
protected override suspend fun deleteChapterChunksForChapter(bookId: String, chapterIndex: Int, styleConfigHash: Int) = Unit
protected override suspend fun deleteAllChapterMetadata() = Unit
protected override suspend fun deletePageCacheMetadataForBook(bookId: String) = Unit
protected override suspend fun deletePageCacheMetadataForChapter(bookId: String, configHash: Int, chapterIndex: Int) = Unit

View file

@ -0,0 +1,30 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Test
class NativeVerticalLocationTest {
@Test
fun `compat page follows native progress`() {
assertEquals(0, nativeVerticalCompatPageForProgress(0f, 101))
assertEquals(50, nativeVerticalCompatPageForProgress(50f, 101))
assertEquals(100, nativeVerticalCompatPageForProgress(100f, 101))
}
@Test
fun `progress follows compat page`() {
assertEquals(0f, nativeVerticalProgressForCompatPage(0, 101), 0.001f)
assertEquals(50f, nativeVerticalProgressForCompatPage(50, 101), 0.001f)
assertEquals(100f, nativeVerticalProgressForCompatPage(100, 101), 0.001f)
}
@Test
fun `progress target skips zero weight chapter gaps`() {
val weights = listOf(0, 100, 300, 600)
assertEquals(1, nativeVerticalProgressToItemIndex(weights, 0f))
assertEquals(2, nativeVerticalProgressToItemIndex(weights, 25f))
assertEquals(3, nativeVerticalProgressToItemIndex(weights, 100f))
}
}

View file

@ -3,6 +3,7 @@ package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.AnnotatedString
import com.aryan.reader.epubreader.HighlightColor
import com.aryan.reader.epubreader.UserHighlight
import com.aryan.reader.shared.ReaderLocator
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
@ -40,7 +41,7 @@ class PaginatedHighlightMappingTest {
}
@Test
fun `same path split block outside stored offsets is ignored`() {
fun `same path split uses cfi offsets as local to block`() {
val block = paragraph(
text = "repeat",
cfi = "/4/2",
@ -51,9 +52,146 @@ class PaginatedHighlightMappingTest {
text = "repeat"
)
assertEquals(0 until 6, getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `same path split block outside stored offsets is ignored`() {
val block = paragraph(
text = "repeat",
cfi = "/4/2",
startOffset = 20
)
val highlight = highlight(
cfi = "/4/2:40|/4/2:46",
text = "repeat"
)
assertNull(getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `desktop locator highlight maps by source offsets`() {
val block = paragraph(
text = "alpha beta gamma",
cfi = null,
startOffset = 20
)
val highlight = highlight(
cfi = "desktop:0:26:30",
text = "beta"
)
assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `locator offsets win over cfi offsets for synced highlights`() {
val block = paragraph(
text = "alpha beta gamma",
cfi = "/4/2",
startOffset = 200
)
val highlight = highlight(
cfi = "/4/2:6|/4/2:10",
text = "beta",
locator = ReaderLocator(
chapterIndex = 0,
startOffset = 206,
endOffset = 210,
cfi = "/4/2:6|/4/2:10",
textQuote = "beta"
)
)
assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `locator offsets prevent cfi fallback from painting unrelated duplicate block`() {
val block = paragraph(
text = "alpha beta gamma",
cfi = "/4/4",
startOffset = 300
)
val highlight = highlight(
cfi = "/4/2:6|/4/2:10",
text = "beta",
locator = ReaderLocator(
chapterIndex = 0,
startOffset = 206,
endOffset = 210,
cfi = "/4/2:6|/4/2:10",
textQuote = "beta"
)
)
assertNull(getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `block local locator offsets do not paint sibling blocks with overlapping local ranges`() {
val highlight = highlight(
cfi = "/4/4/6:124|/4/4/6:248",
text = "selected text",
locator = ReaderLocator(
chapterIndex = 8,
pageIndex = 49,
startOffset = 124,
endOffset = 248,
blockIndex = 1,
charOffset = 124,
textQuote = "selected text",
cfi = "/4/4/6:124|/4/4/6:248"
)
)
val selectedBlock = paragraph(
text = "x".repeat(260),
cfi = "/4/4/6",
startOffset = 0,
blockIndex = 1
)
val siblingBlock = paragraph(
text = "x".repeat(684),
cfi = "/4/4/8",
startOffset = 0,
blockIndex = 2
)
assertEquals(124 until 248, getHighlightOffsetsInBlock(selectedBlock, highlight))
assertNull(getHighlightOffsetsInBlock(siblingBlock, highlight))
}
@Test
fun `source cfi local offsets map within nonzero source block`() {
val block = paragraph(
text = "alpha beta gamma",
cfi = "/4/2",
startOffset = 200
)
val highlight = highlight(
cfi = "/4/2:6|/4/2:10",
text = "beta"
)
assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `legacy absolute cfi offsets remain supported for synced highlights`() {
val block = paragraph(
text = "alpha beta gamma",
cfi = "/4/2",
startOffset = 200
)
val highlight = highlight(
cfi = "/4/2:206|/4/2:210",
text = "beta"
)
assertEquals(6 until 10, getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `paginated page highlights are scoped to page chapter`() {
val chapterFourHighlight = highlight(
@ -85,29 +223,36 @@ class PaginatedHighlightMappingTest {
private fun paragraph(
text: String,
cfi: String,
startOffset: Int
cfi: String?,
startOffset: Int,
blockIndex: Int = startOffset
): ParagraphBlock {
return ParagraphBlock(
content = AnnotatedString(text),
cfi = cfi,
startCharOffsetInSource = startOffset,
endCharOffsetInSource = startOffset + text.length,
blockIndex = startOffset
blockIndex = blockIndex
)
}
private fun highlight(
cfi: String,
text: String,
chapterIndex: Int = 0
chapterIndex: Int = 0,
locator: ReaderLocator = ReaderLocator.fromLegacy(
chapterIndex = chapterIndex,
cfi = cfi,
textQuote = text
)
): UserHighlight {
return UserHighlight(
id = "highlight",
cfi = cfi,
text = text,
color = HighlightColor.YELLOW,
chapterIndex = chapterIndex
chapterIndex = chapterIndex,
locator = locator
)
}
}

View file

@ -0,0 +1,26 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Test
class PaginatorMeasurementContractTest {
@Test
fun measuredTextHeightForPagination_keepsLayoutHeightWhenItContainsLastLineBottom() {
val measuredHeight = measuredTextHeightForPagination(
layoutHeightPx = 120,
lastLineBottomPx = 119.2f
)
assertEquals(120, measuredHeight)
}
@Test
fun measuredTextHeightForPagination_usesCeiledLastLineBottomWhenItExceedsLayoutHeight() {
val measuredHeight = measuredTextHeightForPagination(
layoutHeightPx = 120,
lastLineBottomPx = 132.1f
)
assertEquals(133, measuredHeight)
}
}

View file

@ -0,0 +1,51 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.buildAnnotatedString
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ReaderLinkAnnotationTest {
@Test
fun urlAnnotationAtOffsetFindsLinkInsideRange() {
val text = linkText()
assertEquals("chapter2.xhtml#start", text.readerUrlAnnotationAtOffset(4))
}
@Test
fun urlAnnotationAtOffsetFindsLinkAtEndBoundary() {
val text = linkText()
assertEquals("chapter2.xhtml#start", text.readerUrlAnnotationAtOffset("Read more".length))
}
@Test
fun urlAnnotationAtOffsetReturnsNullOutsideRange() {
val text = buildAnnotatedString {
append("Read more later")
addStringAnnotation("URL", "chapter2.xhtml#start", 0, "Read more".length)
}
assertNull(text.readerUrlAnnotationAtOffset(text.length))
}
@Test
fun readerExternalHrefDetectsCommonExternalSchemesCaseInsensitively() {
assertTrue("HTTPS://example.com".isReaderExternalHref())
assertTrue("//example.com/path".isReaderExternalHref())
assertTrue("mailto:test@example.com".isReaderExternalHref())
assertTrue("tel:+1234567890".isReaderExternalHref())
assertFalse("chapter2.xhtml#start".isReaderExternalHref())
assertFalse("#footnote-1".isReaderExternalHref())
}
private fun linkText() = buildAnnotatedString {
val label = "Read more"
append(label)
addStringAnnotation("URL", "chapter2.xhtml#start", 0, label.length)
}
}

View file

@ -0,0 +1,83 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import com.aryan.reader.SearchResult
import org.junit.Assert.assertEquals
import org.junit.Test
class ReaderNavigationTargetsTest {
@Test
fun `search locator resolves exact occurrence offset in text block`() {
val blocks = listOf(
ParagraphBlock(
content = AnnotatedString("first target then second target"),
cfi = "/4/2",
startCharOffsetInSource = 100,
endCharOffsetInSource = 131,
blockIndex = 7
)
)
val result = SearchResult(
locationInSource = 3,
locationTitle = "Chapter",
snippet = AnnotatedString("second target"),
query = "target",
occurrenceIndexInLocation = 1,
chunkIndex = 0
)
assertEquals(
Locator(chapterIndex = 3, blockIndex = 7, charOffset = 125),
findLocatorForSearchResultInBlocks(result, blocks)
)
}
@Test
fun `anchor locator resolves string annotation offset`() {
val content = buildAnnotatedString {
append("before anchored text")
addStringAnnotation(tag = "ID", annotation = "anchor-1", start = 7, end = 15)
}
val blocks = listOf(
ParagraphBlock(
content = content,
cfi = "/4/4",
startCharOffsetInSource = 40,
endCharOffsetInSource = 60,
blockIndex = 9
)
)
assertEquals(
Locator(chapterIndex = 2, blockIndex = 9, charOffset = 47),
findLocatorForAnchorInBlocks(chapterIndex = 2, anchor = "anchor-1", blocks = blocks)
)
}
@Test
fun `anchor locator resolves non text block element id`() {
val blocks = listOf(
ImageBlock(
path = "images/cover.jpg",
altText = "Cover",
elementId = "cover-image",
cfi = "/4/6",
blockIndex = 11
)
)
assertEquals(
Locator(chapterIndex = 5, blockIndex = 11, charOffset = 0),
findLocatorForAnchorInBlocks(chapterIndex = 5, anchor = "cover-image", blocks = blocks)
)
}
@Test
fun `native vertical initial prefetch is bounded around requested chapter`() {
assertEquals(
listOf(4, 5, 2),
nativeVerticalInitialChapterPrefetchOrder(chapterCount = 6, initialChapter = 3)
)
}
}

View file

@ -64,6 +64,42 @@ class BookCacheDaoTest {
assertArrayEquals(largePayload, large.contentBlocksProto)
}
@Test
fun `processed chapters are isolated by style config hash`() = runTest {
val firstPayload = ByteArray(950 * 1024) { 1 }
val secondPayload = byteArrayOf(2, 3, 4)
dao.insertProcessedChapters(
listOf(
ProcessedChapter(
bookId = "book",
chapterIndex = 0,
contentBlocksProto = firstPayload,
estimatedPageCount = 10,
styleConfigHash = 111
)
)
)
dao.insertProcessedChapters(
listOf(
ProcessedChapter(
bookId = "book",
chapterIndex = 0,
contentBlocksProto = secondPayload,
estimatedPageCount = 2,
styleConfigHash = 222
)
)
)
val firstCached = dao.getProcessedChapter("book", 0, 111)!!
val secondCached = dao.getProcessedChapter("book", 0, 222)!!
assertEquals(111, firstCached.styleConfigHash)
assertEquals(222, secondCached.styleConfigHash)
assertArrayEquals(firstPayload, firstCached.contentBlocksProto)
assertArrayEquals(secondPayload, secondCached.contentBlocksProto)
}
@Test
fun `delete and clear operations remove book chapters anchors and configuration cache`() = runTest {
dao.insertProcessedBook(ProcessedBook("book", LATEST_PROCESSING_VERSION, 10))

View file

@ -50,17 +50,54 @@ class PdfReaderPreferencesTest {
val context = contextWithPrefs(prefs)
savePdfHiddenTools(context, setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SHARE.name))
savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name))
savePdfBottomTools(context, setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name))
savePdfToolOrder(context, listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH))
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(setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name), loadPdfBottomTools(context))
assertEquals(listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH), loadPdfToolOrder(context).take(2))
}
@Test
fun `toolbar restore helpers keep saveable tab switch state sanitized`() {
val restoredOrder = restorePdfToolOrderNames(
listOf(
PdfReaderTool.SEARCH.name,
"NO_SUCH_TOOL",
PdfReaderTool.TOC.name,
PdfReaderTool.SEARCH.name
)
)
val expectedTools = PdfReaderTool.entries.filter(::isPdfReaderToolAvailable)
assertEquals(listOf(PdfReaderTool.SEARCH, PdfReaderTool.TOC), restoredOrder.take(2))
assertEquals(expectedTools.size, restoredOrder.size)
assertEquals(expectedTools.toSet(), restoredOrder.toSet())
assertEquals(
setOf(PdfReaderTool.PRINT.name),
sanitizePdfHiddenToolNames(listOf(PdfReaderTool.PRINT.name, "NO_SUCH_TOOL"))
)
assertEquals(
setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name),
sanitizePdfBottomToolNames(listOf(PdfReaderTool.SEARCH.name, PdfReaderTool.THEME.name, PdfReaderTool.PRINT.name))
)
assertEquals(
defaultPdfBottomTools(),
loadPdfBottomTools(
contextWithPrefs(InMemorySharedPreferences(PDF_BOTTOM_TOOLS_KEY to setOf("NO_SUCH_TOOL")))
)
)
assertEquals(
emptySet<String>(),
loadPdfBottomTools(
contextWithPrefs(InMemorySharedPreferences(PDF_BOTTOM_TOOLS_KEY to emptySet<String>()))
)
)
}
@Test
fun `reader mode and enum preferences default safely when saved values are invalid`() {
val prefs = InMemorySharedPreferences(

View file

@ -60,6 +60,45 @@ class PdfReaderRepositoryTest {
assertNull(repository.getAnnotationFileForSync("book"))
}
@Test
fun `PdfAnnotationRepository does not rewrite unchanged annotation file`() = runTest {
val context = contextWithFilesDir(tempRoot("annotation-noop"))
val repository = PdfAnnotationRepository(context)
val annotations = mapOf(
0 to listOf(
PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = 0,
points = listOf(PdfPoint(0.1f, 0.2f, 123L)),
color = Color.Blue,
strokeWidth = 0.01f
)
)
)
repository.saveAnnotations("book", annotations)
val file = requireNotNull(repository.getAnnotationFileForSync("book"))
val previousModified = 1_700_000_000_000L
assertTrue(file.setLastModified(previousModified))
repository.saveAnnotations("book", annotations)
assertEquals(previousModified, file.lastModified())
}
@Test
fun `PdfAnnotationRepository stores deleted annotation tombstones for sync`() = runTest {
val context = contextWithFilesDir(tempRoot("annotation-deleted"))
val repository = PdfAnnotationRepository(context)
repository.markAnnotationsDeleted("book", listOf("old-ink"), deletedAt = 123L)
val file = requireNotNull(repository.getDeletedAnnotationsFileForSync("book"))
assertTrue(file.readText().contains("old-ink"))
assertTrue(file.readText().contains("123"))
}
@Test
fun `PdfHighlightRepository saves loads deletes empty highlights and clears all`() = runTest {
val context = contextWithFilesDir(tempRoot("highlights"))
@ -86,6 +125,29 @@ class PdfReaderRepositoryTest {
assertFalse(File(context.filesDir, "pdf_highlights").exists())
}
@Test
fun `PdfHighlightRepository does not rewrite unchanged highlight file`() = runTest {
val context = contextWithFilesDir(tempRoot("highlights-noop"))
val repository = PdfHighlightRepository(context)
val highlight = PdfUserHighlight(
id = "h1",
pageIndex = 2,
bounds = emptyList(),
color = PdfHighlightColor.GREEN,
text = "quote",
range = 5 to 10
)
repository.saveHighlights("book", listOf(highlight))
val file = repository.getFileForSync("book")
val previousModified = 1_700_000_000_000L
assertTrue(file.setLastModified(previousModified))
repository.saveHighlights("book", listOf(highlight))
assertEquals(previousModified, file.lastModified())
}
@Test
fun `PdfTextBoxRepository saves loads deletes and clears files`() = runTest {
val context = contextWithFilesDir(tempRoot("textboxes"))
@ -112,6 +174,30 @@ class PdfReaderRepositoryTest {
assertTrue(File(context.filesDir, "textboxes").listFiles().orEmpty().isEmpty())
}
@Test
fun `PdfTextBoxRepository does not rewrite unchanged textbox file`() = runTest {
val context = contextWithFilesDir(tempRoot("textboxes-noop"))
val repository = PdfTextBoxRepository(context)
val box = PdfTextBox(
id = "box",
pageIndex = 0,
relativeBounds = Rect(0.1f, 0.2f, 0.3f, 0.4f),
text = "Text box",
color = Color.Black,
backgroundColor = Color.White,
fontSize = 16f
)
repository.saveTextBoxes("book", listOf(box))
val file = repository.getFileForSync("book")
val previousModified = 1_700_000_000_000L
assertTrue(file.setLastModified(previousModified))
repository.saveTextBoxes("book", listOf(box))
assertEquals(previousModified, file.lastModified())
}
@Test
fun `PageLayoutRepository returns default pdf pages when no layout exists`() = runTest {
val repository = PageLayoutRepository(contextWithFilesDir(tempRoot("layout-default")))

View file

@ -181,6 +181,13 @@ class PdfReaderRichTextTest {
assertTrue("${PAGE_BREAK_CHAR}\nVisible".hasRenderableRichText())
}
@Test
fun `selection bounds normalize reversed and clamped rich text selections`() {
assertEquals(44 to 45, androidPdfRichTextSelectionBounds(45, 44, textLength = 45))
assertEquals(0 to 5, androidPdfRichTextSelectionBounds(-3, 99, textLength = 5))
assertEquals(null, androidPdfRichTextSelectionBounds(3, 3, textLength = 5))
}
@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"

View file

@ -121,7 +121,7 @@ class PdfReaderSettingsAndSharedModelsTest {
@Test
fun `SharedPdfAnnotationDefaults supplies expected tool defaults and palettes`() {
assertEquals(5, SharedPdfAnnotationDefaults.penPalette.size)
assertEquals(5, SharedPdfAnnotationDefaults.highlighterPalette.size)
assertEquals(SharedPdfHighlighterPalette.MaxColors, SharedPdfAnnotationDefaults.highlighterPalette.size)
val pen = SharedPdfAnnotationDefaults.configFor(PdfInkTool.PEN)
val eraser = SharedPdfAnnotationDefaults.configFor(PdfInkTool.ERASER)
@ -204,6 +204,17 @@ class PdfReaderSettingsAndSharedModelsTest {
PdfToolbarSection.BOTTOM,
defaultItems.single { it.tool == PdfReaderTool.SLIDER }.section
)
val customPlacementItems = buildPdfToolbarItems(
hiddenTools = emptySet(),
toolOrder = defaultPdfToolOrder(),
bottomTools = setOf(PdfReaderTool.THEME.name)
)
assertEquals(
PdfToolbarSection.BOTTOM,
customPlacementItems.single { it.tool == PdfReaderTool.THEME }.section
)
val expectedMoreTools = buildSet {
addAll(
setOf(

View file

@ -0,0 +1,40 @@
package com.aryan.reader.tts
import android.content.Context
import org.junit.Assert.assertTrue
import org.junit.Before
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 TtsCacheManagerSecurityTest {
private lateinit var context: Context
@Before
fun setUp() {
context = RuntimeEnvironment.getApplication()
}
@Test
fun `book cache directory for traversal title remains inside tts cache root`() {
val manager = TtsCacheManager(context)
val root = File(context.filesDir, "TTS_Cache").canonicalFile
val cacheDir = manager.getBookCacheDir("..").canonicalFile
assertTrue(cacheDir.path.startsWith(root.path + File.separator))
}
@Test
fun `clearBookCache with traversal title does not delete app files directory`() {
val sentinel = File(context.filesDir, "tts-sentinel-${System.nanoTime()}.txt")
sentinel.writeText("keep")
TtsCacheManager(context).clearBookCache("..")
assertTrue(sentinel.exists())
sentinel.delete()
}
}

View file

@ -60,6 +60,13 @@ class TtsChunkNavigationTest {
assertEquals(true, shouldStartTtsTransitionPrefetch(currentGeneration = 6, deferredGeneration = -1))
}
@Test
fun `prefetch stops only when generated chunk is neither loaded nor queued`() {
assertEquals(true, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = false, playlistIndex = null))
assertEquals(false, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = true, playlistIndex = null))
assertEquals(false, shouldStopTtsPrefetchAfterMissingChunk(isLoaded = false, playlistIndex = 2))
}
@androidx.annotation.OptIn(UnstableApi::class)
@Test
fun `reader tts mini bar is visible only for active reader playback outside reader routes`() {
@ -90,6 +97,45 @@ class TtsChunkNavigationTest {
assertEquals(16, readerTtsMiniBarBottomPaddingDp(isOnMainRoute = false))
}
@Test
fun `reader tts overlay size exposes the other two sizes as choices`() {
assertEquals(
listOf(ReaderTtsOverlaySize.MEDIUM, ReaderTtsOverlaySize.SMALL),
readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.LARGE)
)
assertEquals(
listOf(ReaderTtsOverlaySize.LARGE, ReaderTtsOverlaySize.SMALL),
readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.MEDIUM)
)
assertEquals(
listOf(ReaderTtsOverlaySize.LARGE, ReaderTtsOverlaySize.MEDIUM),
readerTtsOverlayAlternativeSizes(ReaderTtsOverlaySize.SMALL)
)
}
@Test
fun `reader tts overlay stored size defaults to large for missing or invalid values`() {
assertEquals(ReaderTtsOverlaySize.MEDIUM, resolveReaderTtsOverlaySize("MEDIUM"))
assertEquals(ReaderTtsOverlaySize.LARGE, resolveReaderTtsOverlaySize(null))
assertEquals(ReaderTtsOverlaySize.LARGE, resolveReaderTtsOverlaySize("FULL"))
}
@Test
fun `reader tts overlay only aligns small state to the trailing edge`() {
assertEquals(0f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.LARGE), 0f)
assertEquals(0f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.MEDIUM), 0f)
assertEquals(1f, readerTtsOverlayAlignmentBias(ReaderTtsOverlaySize.SMALL), 0f)
}
@Test
fun `reader tts chunk label uses one based progress`() {
assertEquals("Chunk 1/4", formatReaderTtsChunkLabel(currentChunkIndex = 0, totalChunks = 4))
assertEquals("Chunk 4/4", formatReaderTtsChunkLabel(currentChunkIndex = 3, totalChunks = 4))
assertNull(formatReaderTtsChunkLabel(currentChunkIndex = -1, totalChunks = 4))
assertNull(formatReaderTtsChunkLabel(currentChunkIndex = 4, totalChunks = 4))
assertNull(formatReaderTtsChunkLabel(currentChunkIndex = 0, totalChunks = 0))
}
@Test
fun `stream pcm duration uses cloud tts audio format`() {
assertEquals(1_000L, resolveTtsStreamPcmDurationMs(totalBytes = 44L + 48_000L))

View file

@ -76,6 +76,14 @@ class TtsModePolicyTest {
assertEquals(TtsPlaybackManager.TtsMode.BASE, mode)
}
@Test
fun `native tts voice list is resolved only when required`() {
assertEquals(false, shouldResolveNativeTtsVoice(preferredVoiceName = null, isOfflineBuild = false))
assertEquals(false, shouldResolveNativeTtsVoice(preferredVoiceName = " ", isOfflineBuild = false))
assertEquals(true, shouldResolveNativeTtsVoice(preferredVoiceName = "voice-id", isOfflineBuild = false))
assertEquals(true, shouldResolveNativeTtsVoice(preferredVoiceName = null, isOfflineBuild = true))
}
@Test
fun `offline native tts ignores saved network voice`() {
val localVoice = voice("local", requiresNetwork = false)