Desktop app (#308)

* Implement build profiles and feature policy for offline desktop builds

* Introduce unified cross-platform Settings Hub

* Refactor main settings into a hierarchical page-based navigation model

* Refactor library projection to use shared multiplatform logic

* Refactor UI state consumption by removing intermediate screen models

* Introduce AndroidSharedStateBridge to centralize state mapping and reduction logic

* Refactor state management for tabs, selection, and pinning to use shared bridge logic

* Refactor file type management and validation into a centralized shared module

* Centralize file type resolution and improve handling of unknown types

* Centralize book import logic with SharedImportPlanner

* Refactor magnifier geometry logic and coordinate mapping

* Properly handle orientation changes in scroll-locked PDF reader

* Add screen orientation controls to EPUB and PDF readers

* Implement right-to-left (RTL) pagination support and refactor reader menus

* Separate right-to-left pagination settings for PDF and EPUB

* Ensure PDF page data is scoped by document key for multi tab support

* Implement theme-aware link styling for the epub reader

* Implement jump history for back and forward navigation in the epub reader

* Improve locator handling and navigation logic in paginated reader mode

* Implement stable pagination navigation and location tracking

* Centralize banner message management and auto-dismiss logic in MainViewModel

* Implement zoom and pan state preservation for PDF pan lock mode

* Enhance reader navigation UI and workspace layout management in desktop app

* Refactor reader navigation sidebar and relocate search controls in desktop app

* Enhance reader UI with redesigned selection menus and bottom sheet overlays

* Implement custom highlight palettes and reader theme customization in desktop app

* Implement cross-platform modal layer and refine reader UI styling

* Improve highlight accuracy and implement metadata enrichment on book open in desktop app

* Implement two-page spread layout for paginated reader on desktop

* Implement persistent caching for book loading and pagination in desktop app

* Implement persistent caching for book loading and pagination in desktop app

* Optimize reader settings updates by separating layout and appearance changes in desktop app

* Improve desktop window branding and native Windows styling

* Enhance reader selection interactions and UI across EPUB and PDF viewers in desktop app

* Refine selection handle positioning and interaction logic

* Implement EPUB selection debug logging and improve handle targeting

* Optimize desktop book loading performance and UI responsiveness

* Implement anchored zoom gestures and rendering optimizations for the Desktop PDF viewer.

* Implement smooth zoom preview for the PDF reader in desktop app

* Optimize PDF rendering performance and responsiveness in the desktop reader

* Implement conditional diagnostic logging and update desktop build configuration

* Implemented hierarchical TOC, custom scrollbars, and improved desktop modal handling

* Added management options for annotations and highlights in the sidebar in desktop app

* Implemented `SharedStableOutlinedTextField` and updated text input fields to use `TextFieldValue` for improved cursor and selection stability.

* Refined library filters and enhanced OPDS functionality in desktop app

* Improved EPUB pagination measurement and implemented layout diagnostic logging for desktop app

* Added PPTX support including document parsing, rendering, and indexing

* Improved PPTX rendering and layout accuracy

* Implemented text autofit support for PPTX rendering

* Enhanced PPTX rendering with support for custom geometry, automatic numbering, table styles, and image opacity

* Improved EPUB pagination accuracy and added layout telemetry in desktop app

* Improved folder synchronization with metadata-only mode and hashed sidecar management in desktop app

* Implemented rich text font scaling and migrated desktop ink tools to custom pointer input handling

* Implemented billing account obfuscation

* Implemented hierarchical folder navigation and improved library selection functionality in desktop app

* Implemented platform-aware directory resolution and multi-platform native library support for desktop

* Added full-screen mode for the reader workspace

* Added PDF zoom indicator and interactive vertical scrollbar with page tooltips

* Refactored speech bubble prefetching to use a limited radius and improved ML detector initialization and lifecycle management

* Updated PDF indexing to replace existing page text and removed search result item keys

* Implemented "preparing" foreground notification for TTS service

* Optimized PDF rendering performance by pre-calculating page-specific annotations

* Refactored desktop packaging tasks and improved distribution configuration

* Optimized EPUB parser memory usage and added path traversal protection

* Refactored WorkManager monitoring logic and added work pruning

* Implemented comprehensive resource cleanup and memory management for WebView-based components to prevent memory leaks

* Implemented bitmap size limits and scaling to prevent canvas rendering errors

* Split long text paragraphs into multiple semantic blocks during HTML parsing

* Implemented local ActionMode for text selection to prevent platform crashes

* Refactored PPTX text layout, optimized HtmlParser block detection, and improved banner dismissal logic

* Added desktop startup splash screen and deferred WebView initialization

* Reorganized settings hub and added separate PDF reader defaults

* Implemented embedded cover extraction and metadata support for MOBI and FB2 formats

* Implemented batching for MetadataExtractionWorker and optimized EPUB metadata extraction performance.

* Implemented procedurally generated book covers and replaced static placeholders

* Redesigned search UI with a top bar and results overlay in desktop app

* Added PDF page gap and overlay visibility options and implemented DesktopBookImporter

* Refactored PDF reader UI with tabbed inspector and improved theme background handling in desktop

* Implemented PDF viewport persistence for zoom and scroll positions in desktop app

* Improved desktop fullscreen implementation and state restoration

* Implemented desktop window state persistence

* Implemented flavor-based branding and ProGuard configuration for desktop builds

* Implemented precise reader positioning and improved highlight rendering logic in desktop app

* Added support for user-editable book metadata

* Enhanced book metadata support and integrated info/edit dialogs

* Implemented embedded EPUB metadata editing

* Improved highlight mapping and added custom scrollbar styling for the reader.

* Reduced desktop WebView bundle size by excluding unused locales and runtime files

* Added neutral pan mode as the default PDF interaction state.

* Refactored library empty states and updated primary navigation tabs in desktop app

* Implemented native paginated reader and unified content rendering architecture in desktop epub reader

* Implemented native EPUB image rendering for desktop and improved block layout spacing with margin collapsing.

* Improved pagination overflow detection in desktop

* Implemented multi-block text selection with interactive handles and CFI support in desktop epub pagination
This commit is contained in:
Aryan 2026-05-15 22:36:51 +05:30 committed by GitHub
parent c0d0e57e79
commit b20ade9946
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
247 changed files with 43321 additions and 7087 deletions

View file

@ -0,0 +1,195 @@
package com.aryan.reader
import com.aryan.reader.shared.SharedSettingsAction
import com.aryan.reader.shared.SharedSettingsDestination
import com.aryan.reader.shared.SharedSettingsHubModel
import com.aryan.reader.shared.SharedSettingsItemModel
import com.aryan.reader.shared.sharedSettingsHubModel
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidSettingsHubModelsTest {
@Test
fun `offline android settings hide network backed sections`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(),
isOssBuild = true,
isOfflineBuild = true,
isDebugBuild = false
)
)
val actions = model.visibleNestedActions()
assertFalse(SharedSettingsAction.AI_SETTINGS in actions)
assertFalse(SharedSettingsAction.HIDE_READER_AI in actions)
assertFalse(SharedSettingsAction.SIGN_IN in actions)
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
assertFalse(SharedSettingsAction.FOLDER_SYNC in actions)
assertFalse(SharedSettingsAction.HELP_FEEDBACK in actions)
assertFalse(SharedSettingsAction.SUPPORT in actions)
assertTrue(SharedSettingsAction.TTS_SETTINGS in actions)
assertTrue(SharedSettingsAction.CUSTOM_FONTS in actions)
}
@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
),
isOssBuild = true,
isOfflineBuild = false,
isDebugBuild = true
)
)
val actions = model.visibleNestedActions()
assertTrue(SharedSettingsAction.AI_SETTINGS in actions)
assertTrue(SharedSettingsAction.HIDE_READER_AI in actions)
assertFalse(SharedSettingsAction.SIGN_OUT in actions)
assertFalse(SharedSettingsAction.CLOUD_SYNC in actions)
assertFalse(SharedSettingsAction.FOLDER_SYNC in actions)
assertFalse(SharedSettingsAction.DEVICE_MANAGEMENT in actions)
assertFalse(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in actions)
assertTrue(SharedSettingsAction.SUPPORT in actions)
assertEquals(
"TTS & AI",
model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title
)
}
@Test
fun `non oss settings do not expose oss ai key settings`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(isProUser = true),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
)
val actions = model.visibleNestedActions()
assertFalse(SharedSettingsAction.AI_SETTINGS in actions)
assertTrue(SharedSettingsAction.HIDE_READER_AI in actions)
assertTrue(SharedSettingsAction.CLOUD_SYNC in actions)
assertFalse(SharedSettingsAction.SUPPORT in actions)
assertEquals(
"TTS",
model.rootCategories.single { it.destination == SharedSettingsDestination.TTS_AI }.title
)
}
@Test
fun `android settings expose debug-only storage actions only in debug`() {
val releaseActions = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
).visibleNestedActions()
val debugActions = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = true
)
).visibleNestedActions()
assertFalse(SharedSettingsAction.EXPORT_LOGS in releaseActions)
assertTrue(SharedSettingsAction.EXPORT_LOGS in debugActions)
}
@Test
fun `android settings reflect global toggles from reader state`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(
isTabsEnabled = true,
useStrictFileFilter = true,
isScreenCaptureProtectionEnabled = true
),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
)
val toggles = model.visibleNestedItems().associateBy { it.action }
assertTrue(toggles.getValue(SharedSettingsAction.TABS_TOGGLE).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.STRICT_FILE_FILTER).checked == true)
assertTrue(toggles.getValue(SharedSettingsAction.SCREEN_CAPTURE_PROTECTION).checked == true)
}
@Test
fun `android extra settings expose home overflow actions without settings duplicate`() {
val model = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = true
)
)
val extraActions = model.page(SharedSettingsDestination.EXTRA).items.map { it.action }
assertTrue(SharedSettingsAction.TABS_TOGGLE in extraActions)
assertTrue(SharedSettingsAction.LANGUAGE in extraActions)
assertTrue(SharedSettingsAction.EXTERNAL_FILE_BEHAVIOR in extraActions)
assertTrue(SharedSettingsAction.STRICT_FILE_FILTER in extraActions)
assertTrue(SharedSettingsAction.CLEAR_BOOK_CACHE in extraActions)
assertTrue(SharedSettingsAction.CLEAR_REFLOW_CACHE in extraActions)
assertTrue(SharedSettingsAction.TEST_PANEL_DETECTION in extraActions)
assertTrue(SharedSettingsAction.TEST_SPEECH_BUBBLE_DETECTION in extraActions)
assertTrue(SharedSettingsAction.CLEAR_CLOUD_LOCAL_DATA in extraActions)
}
@Test
fun `cloud sync row is gated by pro state`() {
val freeSync = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(isProUser = false),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
).visibleNestedItems().single { it.action == SharedSettingsAction.CLOUD_SYNC }
val proSync = sharedSettingsHubModel(
androidSettingsHubInput(
uiState = ReaderScreenState(isProUser = true),
isOssBuild = false,
isOfflineBuild = false,
isDebugBuild = false
)
).visibleNestedItems().single { it.action == SharedSettingsAction.CLOUD_SYNC }
assertFalse(freeSync.enabled)
assertTrue(proSync.enabled)
}
}
private fun SharedSettingsHubModel.visibleNestedItems(): List<SharedSettingsItemModel> {
return rootCategories.flatMap { category ->
page(category.destination).items
}
}
private fun SharedSettingsHubModel.visibleNestedActions(): List<SharedSettingsAction> {
return visibleNestedItems().map { it.action }
}

View file

@ -0,0 +1,230 @@
package com.aryan.reader
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.AppAction as SharedAppAction
import com.aryan.reader.shared.AppThemeMode as SharedAppThemeMode
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class AndroidSharedStateBridgeTest {
@Test
fun `prepareLibraryProjection builds shared input and Android lookup context`() {
val tag = tag("tag", "Favorite")
val book = recentFile("book", sourceFolderUri = "content://folder")
val reflowCopy = recentFile("book_reflow", sourceFolderUri = "content://folder")
val context = AndroidSharedStateBridge.prepareLibraryProjection(
input = LibraryProjectionInput(
state = ReaderScreenState(),
recentFilesFromDb = listOf(book, reflowCopy),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = book.bookId, tagId = tag.id))
),
folderPathResolver = EmptyFolderPathResolver
)
assertEquals(listOf("book"), context.androidBooksById.keys.toList())
assertEquals(listOf("book"), context.sharedInput.booksFromStore.map { it.id })
assertEquals(listOf("tag"), context.sharedInput.booksFromStore.single().tags.map { it.id })
assertEquals(listOf(AndroidSharedFolderProjectionKey("content://folder", "Local Folder")), context.folderKeys)
}
@Test
fun `reduceLibraryAction applies shared library state back to Android fields`() {
val book = recentFile("book")
val filters = LibraryFilters(readStatus = ReadStatusFilter.COMPLETED)
val selected = AndroidSharedStateBridge.reduceLibraryAction(
current = ReaderScreenState(),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(book)),
action = SharedLibraryAction.BookSelectionToggled(book.bookId)
)
val filtered = AndroidSharedStateBridge.reduceLibraryAction(
current = selected,
projectedState = ReaderScreenState(rawLibraryFiles = listOf(book)),
action = SharedLibraryAction.FiltersChanged(filters.toSharedLibraryFilters())
)
assertEquals(setOf(book), selected.contextualActionItems)
assertEquals(filters, filtered.libraryFilters)
}
@Test
fun `reduceLibraryAction drops selection ids that are not in projected Android books`() {
val result = AndroidSharedStateBridge.reduceLibraryAction(
current = ReaderScreenState(),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(recentFile("book"))),
action = SharedLibraryAction.BookSelectionToggled("missing")
)
assertTrue(result.contextualActionItems.isEmpty())
}
@Test
fun `reduceAppAction applies shared app state back to Android fields`() {
val result = AndroidSharedStateBridge.reduceAppAction(
current = ReaderScreenState(appThemeMode = AppThemeMode.LIGHT),
projectedState = ReaderScreenState(),
action = SharedAppAction.AppThemeChanged(SharedAppThemeMode.DARK)
)
assertEquals(AppThemeMode.DARK, result.appThemeMode)
}
@Test
fun `setTabsEnabled disables shared tabs but preserves Android active reader session`() {
val result = AndroidSharedStateBridge.setTabsEnabled(
current = ReaderScreenState(
isTabsEnabled = true,
openTabIds = listOf("one", "two"),
activeTabBookId = "two"
),
projectedState = ReaderScreenState(),
enabled = false
)
assertEquals(false, result.isTabsEnabled)
assertEquals(listOf("two"), result.openTabIds)
assertEquals("two", result.activeTabBookId)
}
@Test
fun `openBookTab delegates tab ordering and activation to shared reducer`() {
val result = AndroidSharedStateBridge.openBookTab(
current = ReaderScreenState(
isTabsEnabled = false,
openTabIds = listOf("old"),
activeTabBookId = "old"
),
projectedState = ReaderScreenState(),
bookId = "new"
)
assertEquals(true, result.isTabsEnabled)
assertEquals(listOf("old", "new"), result.openTabIds)
assertEquals("new", result.activeTabBookId)
}
@Test
fun `closeBookTab selects the previous tab when the active tab closes`() {
val result = AndroidSharedStateBridge.closeBookTab(
current = ReaderScreenState(
isTabsEnabled = true,
openTabIds = listOf("one", "two", "three"),
activeTabBookId = "three"
),
projectedState = ReaderScreenState(),
bookId = "three"
)
assertEquals(true, result.isTabsEnabled)
assertEquals(listOf("one", "two"), result.openTabIds)
assertEquals("two", result.activeTabBookId)
}
@Test
fun `closeAllTabs clears Android tab ids through shared reducer`() {
val result = AndroidSharedStateBridge.closeAllTabs(
current = ReaderScreenState(
isTabsEnabled = true,
openTabIds = listOf("one", "two"),
activeTabBookId = "two"
),
projectedState = ReaderScreenState()
)
assertEquals(true, result.isTabsEnabled)
assertTrue(result.openTabIds.isEmpty())
assertEquals(null, result.activeTabBookId)
}
@Test
fun `togglePinsForSelectedBooks pins mixed home selection and clears selection`() {
val pinned = recentFile("pinned")
val unpinned = recentFile("unpinned")
val result = AndroidSharedStateBridge.togglePinsForSelectedBooks(
current = ReaderScreenState(
rawLibraryFiles = listOf(pinned, unpinned),
contextualActionItems = setOf(pinned, unpinned),
pinnedHomeBookIds = setOf(pinned.bookId)
),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(pinned, unpinned)),
isHome = true
)
assertEquals(setOf("pinned", "unpinned"), result.pinnedHomeBookIds)
assertTrue(result.contextualActionItems.isEmpty())
}
@Test
fun `togglePinsForSelectedBooks unpins when all selected library books are pinned`() {
val first = recentFile("first")
val second = recentFile("second")
val result = AndroidSharedStateBridge.togglePinsForSelectedBooks(
current = ReaderScreenState(
rawLibraryFiles = listOf(first, second),
contextualActionItems = setOf(first, second),
pinnedLibraryBookIds = setOf(first.bookId, second.bookId)
),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(first, second)),
isHome = false
)
assertTrue(result.pinnedLibraryBookIds.isEmpty())
assertTrue(result.contextualActionItems.isEmpty())
}
@Test
fun `replaceBookSelectionWithVisibleBooks selects visible books through shared reducer`() {
val visible = recentFile("visible")
val hidden = recentFile("hidden")
val result = AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks(
current = ReaderScreenState(),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(visible, hidden)),
visibleBooks = listOf(visible)
)
assertEquals(setOf(visible), result.contextualActionItems)
}
@Test
fun `replaceBookSelectionWithVisibleBooks clears when visible books are already selected`() {
val visible = recentFile("visible")
val result = AndroidSharedStateBridge.replaceBookSelectionWithVisibleBooks(
current = ReaderScreenState(contextualActionItems = setOf(visible)),
projectedState = ReaderScreenState(rawLibraryFiles = listOf(visible)),
visibleBooks = listOf(visible)
)
assertTrue(result.contextualActionItems.isEmpty())
}
private fun recentFile(
id: String,
sourceFolderUri: String? = null
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = FileType.EPUB,
displayName = "$id.epub",
timestamp = 1L,
sourceFolderUri = sourceFolderUri
)
private fun tag(id: String, name: String) = TagEntity(
id = id,
name = name,
createdAt = 1L
)
}

View file

@ -0,0 +1,215 @@
package com.aryan.reader
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.Base64
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@RunWith(RobolectricTestRunner::class)
class EmbeddedEbookMetadataExtractorTest {
@Test
fun `epub extracts text metadata and explicitly referenced cover image`() {
val coverBytes = onePixelPngBytes()
val epubBytes = zipBytes(
"META-INF/container.xml" to """
<container>
<rootfiles>
<rootfile full-path="OEBPS/content.opf"/>
</rootfiles>
</container>
""".trimIndent().toByteArray(Charsets.UTF_8),
"OEBPS/content.opf" to """
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:title>Folder EPUB</dc:title>
<dc:creator>Octavia Butler</dc:creator>
<dc:description>&lt;p&gt;Folder summary&lt;/p&gt;</dc:description>
<meta content="Patternist" name="calibre:series"/>
<meta content="3" name="calibre:series_index"/>
<meta name="cover" content="cover-image"/>
</metadata>
<manifest>
<item id="cover-image" href="images/cover.png" media-type="image/png"/>
</manifest>
</package>
""".trimIndent().toByteArray(Charsets.UTF_8),
"OEBPS/images/cover.png" to coverBytes
)
val metadata = EmbeddedEbookMetadataExtractor.extract(
type = FileType.EPUB,
displayName = "folder.epub",
openStream = { ByteArrayInputStream(epubBytes) }
)
assertEquals("Folder EPUB", metadata.title)
assertEquals("Octavia Butler", metadata.author)
assertEquals("<p>Folder summary</p>", metadata.description)
assertEquals("Patternist", metadata.seriesName)
assertEquals(3.0, metadata.seriesIndex)
val cover = metadata.cover
assertNotNull(cover)
assertEquals("png", cover!!.extension)
assertArrayEquals(coverBytes, cover.bytes)
}
@Test
fun `fb2 extracts coverpage binary without parsing book body`() {
val coverBytes = onePixelPngBytes()
val fb2 = """
<FictionBook xmlns:l="http://www.w3.org/1999/xlink">
<description>
<title-info>
<book-title>Folder FB2</book-title>
<author>
<first-name>Ursula</first-name>
<last-name>Le Guin</last-name>
</author>
<annotation><image l:href="#not-cover.png"/></annotation>
<coverpage><image l:href="#cover.png"/></coverpage>
</title-info>
</description>
<body><section><p>Body text should not matter.</p></section></body>
<binary id="not-cover.png" content-type="image/png">${Base64.getEncoder().encodeToString(ByteArray(0))}</binary>
<binary id="cover.png" content-type="image/png">${Base64.getEncoder().encodeToString(coverBytes)}</binary>
</FictionBook>
""".trimIndent()
val metadata = EmbeddedEbookMetadataExtractor.extract(
type = FileType.FB2,
displayName = "folder.fb2",
openStream = { ByteArrayInputStream(fb2.toByteArray(Charsets.UTF_8)) }
)
assertEquals("Folder FB2", metadata.title)
assertEquals("Ursula Le Guin", metadata.author)
val cover = metadata.cover
assertNotNull(cover)
assertEquals("png", cover!!.extension)
assertArrayEquals(coverBytes, cover.bytes)
}
@Test
fun `mobi extracts EXTH text metadata and embedded cover record`() {
val coverBytes = onePixelPngBytes()
val mobiBytes = minimalMobiBytes(
title = "Folder MOBI",
author = "N K Jemisin",
coverBytes = coverBytes
)
val metadata = EmbeddedEbookMetadataExtractor.extract(
type = FileType.MOBI,
displayName = "folder.mobi",
openStream = { ByteArrayInputStream(mobiBytes) }
)
assertEquals("Folder MOBI", metadata.title)
assertEquals("N K Jemisin", metadata.author)
val cover = metadata.cover
assertNotNull(cover)
assertEquals("png", cover!!.extension)
assertArrayEquals(coverBytes, cover.bytes)
}
private fun zipBytes(vararg entries: Pair<String, ByteArray>): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
entries.forEach { (name, content) ->
zip.putNextEntry(ZipEntry(name))
zip.write(content)
zip.closeEntry()
}
}
return out.toByteArray()
}
private fun minimalMobiBytes(title: String, author: String, coverBytes: ByteArray): ByteArray {
val exthRecords = listOf(
exthStringRecord(99, title),
exthStringRecord(100, author),
exthIntRecord(201, 0)
)
val exthSize = 12 + exthRecords.sumOf { it.size }
val mobiHeaderLength = 232
val record0 = ByteArray(16 + mobiHeaderLength + exthSize)
putU16(record0, 0, 1)
putU32(record0, 4, 0)
putU16(record0, 8, 0)
putU16(record0, 12, 0)
putAscii(record0, 16, "MOBI")
putU32(record0, 20, mobiHeaderLength)
putU32(record0, 16 + 12, 65001)
putU32(record0, 16 + 68, 0)
putU32(record0, 16 + 72, 0)
putU32(record0, 16 + 92, 1)
val exthOffset = 16 + mobiHeaderLength
putAscii(record0, exthOffset, "EXTH")
putU32(record0, exthOffset + 4, exthSize)
putU32(record0, exthOffset + 8, exthRecords.size)
var cursor = exthOffset + 12
exthRecords.forEach { record ->
record.copyInto(record0, cursor)
cursor += record.size
}
val palmHeader = ByteArray(78 + 8 * 2)
putU16(palmHeader, 76, 2)
val record0Offset = palmHeader.size
val coverOffset = record0Offset + record0.size
putU32(palmHeader, 78, record0Offset)
putU32(palmHeader, 86, coverOffset)
return palmHeader + record0 + coverBytes
}
private fun exthStringRecord(type: Int, value: String): ByteArray {
val data = value.toByteArray(Charsets.UTF_8)
return exthRecord(type, data)
}
private fun exthIntRecord(type: Int, value: Int): ByteArray {
val data = ByteArray(4)
putU32(data, 0, value)
return exthRecord(type, data)
}
private fun exthRecord(type: Int, data: ByteArray): ByteArray {
val record = ByteArray(8 + data.size)
putU32(record, 0, type)
putU32(record, 4, record.size)
data.copyInto(record, 8)
return record
}
private fun putAscii(target: ByteArray, offset: Int, value: String) {
value.toByteArray(Charsets.US_ASCII).copyInto(target, offset)
}
private fun putU16(target: ByteArray, offset: Int, value: Int) {
target[offset] = ((value ushr 8) and 0xFF).toByte()
target[offset + 1] = (value and 0xFF).toByte()
}
private fun putU32(target: ByteArray, offset: Int, value: Int) {
target[offset] = ((value ushr 24) and 0xFF).toByte()
target[offset + 1] = ((value ushr 16) and 0xFF).toByte()
target[offset + 2] = ((value ushr 8) and 0xFF).toByte()
target[offset + 3] = (value and 0xFF).toByte()
}
private fun onePixelPngBytes(): ByteArray {
return Base64.getDecoder().decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="
)
}
}

View file

@ -2,6 +2,7 @@ package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@ -20,6 +21,21 @@ class FileTypeResolverTest {
assertEquals(FileType.HTML, resolveFileTypeFromName("table.csv"))
assertEquals(FileType.HTML, resolveFileTypeFromName("script.kt"))
assertEquals(FileType.HTML, resolveFileTypeFromName("payload.json.txt"))
assertEquals(com.aryan.reader.shared.SharedFileCapabilities.resolveFileTypeForName("payload.json.txt"), resolveFileTypeFromName("payload.json.txt"))
}
@Test
fun `metadata resolver maps provider mime types without exposing generic archives`() {
assertEquals(FileType.PDF, resolveFileTypeFromMetadata("download", "application/pdf"))
assertEquals(FileType.DOCX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.wordprocessingml.document"))
assertEquals(FileType.PPTX, resolveFileTypeFromMetadata("download", "application/vnd.openxmlformats-officedocument.presentationml.presentation"))
assertEquals(FileType.MD, resolveFileTypeFromMetadata("notes.markdown.txt", "text/plain; charset=utf-8"))
assertEquals(FileType.EPUB, resolveFileTypeFromMetadata("book.epub.txt", "text/plain"))
assertEquals(FileType.TXT, resolveFileTypeFromMetadata("notes", "text/plain"))
assertEquals(FileType.HTML, resolveFileTypeFromMetadata("payload", "application/json"))
assertEquals(FileType.CBZ, resolveFileTypeFromMetadata("comic.cbz", "application/zip"))
assertEquals(FileType.FB2, resolveFileTypeFromMetadata("book.fb2.zip", "application/zip"))
assertNull(resolveFileTypeFromMetadata("archive.zip", "application/zip"))
}
@Test
@ -39,7 +55,9 @@ class FileTypeResolverTest {
@Test
fun `plain txt remains txt when inner extension is unsupported`() {
assertEquals(FileType.TXT, resolveFileTypeFromName("notes.txt"))
assertEquals(FileType.PPTX, resolveFileTypeFromName("deck.pptx"))
assertEquals(FileType.TXT, resolveFileTypeFromName("archive.unknown.txt"))
assertNull(resolveFileTypeFromName("archive.zip"))
}
@Test

View file

@ -32,6 +32,13 @@ class LibraryStateProjectorTest {
assertEquals(files.ids(), filterBySearch(files, " ").ids())
}
@Test
fun `filterBySearch preserves android display-name matching when a custom name exists`() {
val file = recentFile("custom", displayName = "Original File.pdf", customName = "Renamed")
assertEquals(listOf("custom"), filterBySearch(listOf(file), "original").ids())
}
@Test
fun `applyLibraryFilters requires all active filters to match`() {
val activeTag = tag("active", "Active")
@ -188,6 +195,7 @@ class LibraryStateProjectorTest {
sortOrder = SortOrder.TITLE_ASC,
recentFilesLimit = 1,
openTabIds = listOf("beta", "missing"),
activeTabBookId = "missing",
contextualActionItems = setOf(recentFile("beta"), recentFile("missing")),
viewingShelfId = "manual",
contextualActionShelfIds = setOf("manual", "missing")
@ -208,6 +216,8 @@ class LibraryStateProjectorTest {
assertEquals(listOf("alpha", "beta", "gamma"), result.rawLibraryFiles.ids())
assertEquals(listOf("beta"), result.recentFiles.ids())
assertEquals(listOf("beta"), result.openTabs.ids())
assertEquals(listOf("beta"), result.openTabIds)
assertNull(result.activeTabBookId)
assertEquals(setOf("beta"), result.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
assertEquals(listOf(tag), result.contextualActionItems.first().tags)
assertEquals("manual", result.viewingShelfId)
@ -266,6 +276,30 @@ class LibraryStateProjectorTest {
assertEquals(listOf(tag), result.allTags)
}
@Test
fun `project keeps pinned home and library books first using shared projector`() {
val older = recentFile("older", title = "Zulu", timestamp = 1L)
val newer = recentFile("newer", title = "Alpha", timestamp = 2L)
val result = LibraryStateProjector().project(
LibraryProjectionInput(
state = ReaderScreenState(
sortOrder = SortOrder.TITLE_ASC,
pinnedHomeBookIds = setOf("older"),
pinnedLibraryBookIds = setOf("older")
),
recentFilesFromDb = listOf(older, newer),
dbShelves = emptyList(),
shelfRefs = emptyList(),
dbTags = emptyList(),
tagRefs = emptyList()
)
)
assertEquals(listOf("older", "newer"), result.recentFiles.ids())
assertEquals(listOf("older", "newer"), result.allRecentFiles.ids())
}
@Test
fun `project builds manual tag series and unshelved shelves`() {
val favorite = tag("favorite", "Favorite")
@ -479,7 +513,8 @@ class LibraryStateProjectorTest {
tags: List<TagEntity> = emptyList(),
fileSize: Long = 0L,
seriesName: String? = null,
seriesIndex: Double? = null
seriesIndex: Double? = null,
customName: String? = null
) = RecentFileItem(
bookId = id,
uriString = uriString,
@ -494,7 +529,8 @@ class LibraryStateProjectorTest {
tags = tags,
fileSize = fileSize,
seriesName = seriesName,
seriesIndex = seriesIndex
seriesIndex = seriesIndex,
customName = customName
)
private fun tag(id: String, name: String) = TagEntity(

View file

@ -9,16 +9,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.credentials.CredentialManager
import androidx.work.WorkManager
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingResult
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.tts.TtsController
import com.aryan.reader.tts.TtsPlaybackManager
import com.google.firebase.auth.FirebaseAuth
import com.google.firebase.firestore.FirebaseFirestore
import io.mockk.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -108,28 +104,8 @@ class MainViewModelTest {
mockkObject(WorkManager.Companion)
val mockWorkManager = mockk<WorkManager>(relaxed = true)
every { WorkManager.getInstance(any()) } returns mockWorkManager
mockkStatic(FirebaseAuth::class)
every { FirebaseAuth.getInstance() } returns mockk(relaxed = true)
mockkStatic(FirebaseFirestore::class)
every { FirebaseFirestore.getInstance() } returns mockk(relaxed = true)
mockkObject(CredentialManager.Companion)
every { CredentialManager.create(any()) } returns mockk(relaxed = true)
mockkStatic(BillingClient::class)
val mockBillingClient = mockk<BillingClient>(relaxed = true)
val mockBillingBuilder = mockk<BillingClient.Builder>(relaxed = true)
every { BillingClient.newBuilder(any()) } returns mockBillingBuilder
every { mockBillingBuilder.setListener(any()) } returns mockBillingBuilder
every { mockBillingBuilder.enablePendingPurchases(any()) } returns mockBillingBuilder
every { mockBillingBuilder.build() } returns mockBillingClient
every { mockBillingClient.isReady } returns false
every { mockBillingClient.startConnection(any()) } answers {
firstArg<com.android.billingclient.api.BillingClientStateListener>()
.onBillingSetupFinished(
BillingResult.newBuilder()
.setResponseCode(BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE)
.build()
)
}
mockkConstructor(AuthRepository::class)
mockkConstructor(RecentFilesRepository::class)
mockkConstructor(BillingClientWrapper::class)
@ -140,6 +116,14 @@ class MainViewModelTest {
mockkConstructor(TtsController::class)
every { anyConstructed<BillingClientWrapper>().proUpgradeState } returns billingStateFlow
every { anyConstructed<BillingClientWrapper>().initializeConnection() } just Runs
every { anyConstructed<BillingClientWrapper>().refreshPurchasesAsync() } just Runs
every { anyConstructed<BillingClientWrapper>().clearVerificationState() } just Runs
every { anyConstructed<BillingClientWrapper>().clearAccountConflict() } just Runs
every { anyConstructed<BillingClientWrapper>().markAccountConflict() } just Runs
every { anyConstructed<BillingClientWrapper>().clearError() } just Runs
every { anyConstructed<BillingClientWrapper>().consumePurchase(any()) } just Runs
every { anyConstructed<BillingClientWrapper>().launchPurchaseFlow(any(), any(), any()) } just Runs
every { anyConstructed<AuthRepository>().getSignedInUser() } returns null
every { anyConstructed<AuthRepository>().observeAuthState() } returns flowOf(null)
every { anyConstructed<RemoteConfigRepository>().init() } just Runs
@ -566,6 +550,36 @@ class MainViewModelTest {
verify { mockEditor.putStringSet(KEY_FILTER_TAG_IDS, filters.tagIds) }
}
@Test
fun `updateLibraryFilters drops unknown file type before state and prefs`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
val requested = LibraryFilters(
fileTypes = setOf(FileType.PDF, FileType.UNKNOWN),
sourceFolders = setOf("content://sync"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf("favorite")
)
val expected = requested.copy(fileTypes = setOf(FileType.PDF))
viewModel.updateLibraryFilters(requested)
val state = viewModel.uiState.first { it.libraryFilters == expected }
assertEquals(expected, state.libraryFilters)
assertFalse(FileType.UNKNOWN in state.libraryFilters.fileTypes)
verify { mockEditor.putStringSet(KEY_FILTER_FILE_TYPES, setOf("PDF")) }
}
@Test
fun `saved library file filters drop stale unknown values during restore`() = runTest {
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)
}
@Test
fun `updateLibraryFilters clears active filters and persists empty dimensions`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
@ -954,6 +968,22 @@ class MainViewModelTest {
assertEquals(null, clearedState.bannerMessage)
}
@Test
fun `persistent banner is not auto dismissed`() = runTest {
backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) {
viewModel.uiState.collect {}
}
viewModel.showBanner("Syncing", isPersistent = true)
viewModel.uiState.first { it.bannerMessage?.message == "Syncing" }
runCurrent()
advanceTimeBy(3_000L)
runCurrent()
assertEquals("Syncing", viewModel.uiState.value.bannerMessage?.message)
}
private fun recentFile(
id: String,
type: FileType = FileType.EPUB,
@ -970,10 +1000,16 @@ class MainViewModelTest {
title = title
)
private fun mockUri(uriString: String): Uri {
private fun mockUri(
uriString: String,
path: String? = uriString.substringAfter(":", ""),
lastPathSegment: String? = path?.substringAfterLast('/')
): Uri {
return mockk<Uri>().also { uri ->
every { uri.toString() } returns uriString
every { uri.scheme } returns uriString.substringBefore(":", "")
every { uri.path } returns path
every { uri.lastPathSegment } returns lastPathSegment
}
}

View file

@ -1,147 +0,0 @@
package com.aryan.reader
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 NonReaderScreenModelsTest {
@Test
fun `home model treats open tabs as non-empty content`() {
val tab = recentFile("tab")
val model = ReaderScreenState(
isTabsEnabled = true,
openTabs = listOf(tab),
rawLibraryFiles = listOf(tab)
).toHomeScreenModel()
assertFalse(model.isEmpty)
assertTrue(model.isLibraryEmpty)
assertEquals(listOf(tab), model.openTabs)
}
@Test
fun `home model reports empty when there are no recents or open tabs`() {
val archivedBook = recentFile("archived", isRecent = false)
val model = ReaderScreenState(
recentFiles = emptyList(),
rawLibraryFiles = listOf(archivedBook)
).toHomeScreenModel()
assertTrue(model.isEmpty)
assertTrue(model.isLibraryEmpty)
}
@Test
fun `home model ignores open tabs for empty state when tabs are disabled`() {
val tab = recentFile("tab")
val model = ReaderScreenState(
isTabsEnabled = false,
openTabs = listOf(tab),
recentFiles = emptyList()
).toHomeScreenModel()
assertTrue(model.isEmpty)
assertEquals(listOf(tab), model.openTabs)
}
@Test
fun `home model exposes contextual selection and device limit state`() {
val selected = recentFile("selected")
val deviceState = DeviceLimitReachedState(isLimitReached = true)
val model = ReaderScreenState(
recentFiles = listOf(selected),
contextualActionItems = setOf(selected),
deviceLimitState = deviceState
).toHomeScreenModel()
assertTrue(model.isContextualModeActive)
assertEquals(setOf(selected), model.selectedItems)
assertEquals(deviceState, model.deviceLimitState)
assertFalse(model.isEmpty)
assertFalse(model.isLibraryEmpty)
}
@Test
fun `library model exposes contextual and shelf selection state`() {
val folderBook = recentFile("folder", sourceFolderUri = "content://folder")
val shelf = Shelf(
id = "manual",
name = "Manual",
type = ShelfType.MANUAL,
books = listOf(folderBook)
)
val model = ReaderScreenState(
contextualActionItems = setOf(folderBook),
contextualActionShelfIds = setOf(shelf.id),
sortOrder = SortOrder.TITLE_ASC,
shelves = listOf(shelf),
rawLibraryFiles = listOf(folderBook),
searchQuery = "folder",
isSearchActive = true
).toLibraryScreenModel()
assertTrue(model.isContextualModeActive)
assertTrue(model.isShelfContextualModeActive)
assertTrue(model.containsFolderItemsInSelection)
assertEquals(setOf(folderBook), model.selectedItems)
assertEquals(setOf(shelf.id), model.selectedShelves)
assertEquals(SortOrder.TITLE_ASC, model.sortOrder)
assertEquals("folder", model.searchQuery)
assertTrue(model.isSearchActive)
}
@Test
fun `library model reports inactive contextual states for normal browsing`() {
val book = recentFile("book")
val model = ReaderScreenState(
allRecentFiles = listOf(book),
rawLibraryFiles = listOf(book),
sortOrder = SortOrder.RECENT
).toLibraryScreenModel()
assertFalse(model.isContextualModeActive)
assertFalse(model.isShelfContextualModeActive)
assertFalse(model.containsFolderItemsInSelection)
assertTrue(model.selectedItems.isEmpty())
assertTrue(model.selectedShelves.isEmpty())
assertEquals(listOf(book), model.rawLibraryFiles)
assertEquals(SortOrder.RECENT, model.sortOrder)
}
@Test
fun `library model distinguishes folder and non-folder selections`() {
val localBook = recentFile("local")
val model = ReaderScreenState(
contextualActionItems = setOf(localBook),
rawLibraryFiles = listOf(localBook)
).toLibraryScreenModel()
assertTrue(model.isContextualModeActive)
assertFalse(model.containsFolderItemsInSelection)
assertEquals(setOf(localBook), model.selectedItems)
}
private fun recentFile(
id: String,
isRecent: Boolean = true,
sourceFolderUri: String? = null
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = FileType.EPUB,
displayName = "$id.epub",
timestamp = 1L,
isRecent = isRecent,
sourceFolderUri = sourceFolderUri
)
}

View file

@ -0,0 +1,27 @@
package com.aryan.reader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PurchaseAccountObfuscatorTest {
@Test
fun `obfuscated account id is stable and safe for billing`() {
val accountId = PurchaseAccountObfuscator.obfuscatedAccountId("firebase-user-123")
assertTrue(accountId.startsWith("firebase_"))
assertFalse(accountId.contains("="))
assertEquals(accountId, PurchaseAccountObfuscator.obfuscatedAccountId("firebase-user-123"))
}
@Test
fun `purchase token hash matches worker format`() {
val token = "jjjnbecgjekfeigbnagcheee.AO-J1OzOurukZmfLAyu6EdPlEvLIyehyOLYajYbGlEK3knhjN4nGe-BLgjXVrSCfRFocGJ5Wc8VcLazRLZxHTdgiUQ5zULRyoQ"
assertEquals(
"sha256_KBe2Ev9nqOx9PMypxP3AlwDgm4E-KIa-i5Eenr1QPF8",
PurchaseAccountObfuscator.purchaseTokenHash(token)
)
}
}

View file

@ -0,0 +1,128 @@
package com.aryan.reader
import android.content.Context
import android.content.SharedPreferences
import android.content.pm.ActivityInfo
import io.mockk.every
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Test
class ReaderScreenOrientationTest {
@Test
fun `screen orientation defaults to follow system and falls back for invalid ids`() {
val defaultContext = contextWithPrefs(InMemorySharedPreferences())
val invalidContext = contextWithPrefs(
InMemorySharedPreferences("reader_screen_orientation_mode" to Int.MIN_VALUE)
)
assertEquals(ReaderScreenOrientationMode.FOLLOW_SYSTEM, loadReaderScreenOrientationMode(defaultContext))
assertEquals(ReaderScreenOrientationMode.FOLLOW_SYSTEM, loadReaderScreenOrientationMode(invalidContext))
}
@Test
fun `screen orientation mode saves loads and maps to activity requested orientation`() {
val context = contextWithPrefs(InMemorySharedPreferences())
saveReaderScreenOrientationMode(context, ReaderScreenOrientationMode.LANDSCAPE)
assertEquals(ReaderScreenOrientationMode.LANDSCAPE, loadReaderScreenOrientationMode(context))
saveReaderScreenOrientationMode(context, ReaderScreenOrientationMode.PORTRAIT)
assertEquals(ReaderScreenOrientationMode.PORTRAIT, loadReaderScreenOrientationMode(context))
assertEquals(
ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED,
ReaderScreenOrientationMode.FOLLOW_SYSTEM.toRequestedOrientation()
)
assertEquals(
ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT,
ReaderScreenOrientationMode.PORTRAIT.toRequestedOrientation()
)
assertEquals(
ActivityInfo.SCREEN_ORIENTATION_USER_LANDSCAPE,
ReaderScreenOrientationMode.LANDSCAPE.toRequestedOrientation()
)
}
@Test
fun `right to left pagination is separate for pdf and epub`() {
val context = contextWithPrefs(InMemorySharedPreferences())
assertEquals(false, loadPdfRightToLeftPagination(context))
assertEquals(false, loadEpubRightToLeftPagination(context))
savePdfRightToLeftPagination(context, true)
assertEquals(true, loadPdfRightToLeftPagination(context))
assertEquals(false, loadEpubRightToLeftPagination(context))
saveEpubRightToLeftPagination(context, true)
assertEquals(true, loadPdfRightToLeftPagination(context))
assertEquals(true, loadEpubRightToLeftPagination(context))
savePdfRightToLeftPagination(context, false)
assertEquals(false, loadPdfRightToLeftPagination(context))
assertEquals(true, loadEpubRightToLeftPagination(context))
}
private fun contextWithPrefs(prefs: SharedPreferences): Context {
val context = mockk<Context>()
every { context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE) } returns prefs
every { context.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE) } returns prefs
return context
}
private class InMemorySharedPreferences(vararg initial: Pair<String, Any?>) : SharedPreferences {
private val values = initial.toMap().toMutableMap()
override fun getAll(): MutableMap<String, *> = values
override fun getString(key: String?, defValue: String?): String? = values[key] as? String ?: defValue
override fun getStringSet(key: String?, defValues: MutableSet<String>?): MutableSet<String>? {
val value = values[key] as? Set<*> ?: return defValues
return value.filterIsInstance<String>().toMutableSet()
}
override fun getInt(key: String?, defValue: Int): Int = values[key] as? Int ?: defValue
override fun getLong(key: String?, defValue: Long): Long = values[key] as? Long ?: defValue
override fun getFloat(key: String?, defValue: Float): Float = values[key] as? Float ?: defValue
override fun getBoolean(key: String?, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
override fun contains(key: String?): Boolean = values.containsKey(key)
override fun edit(): SharedPreferences.Editor = Editor()
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
private inner class Editor : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearRequested = false
override fun putString(key: String?, value: String?): SharedPreferences.Editor = applyPut(key, value)
override fun putStringSet(key: String?, values: MutableSet<String>?): SharedPreferences.Editor =
applyPut(key, values?.toSet())
override fun putInt(key: String?, value: Int): SharedPreferences.Editor = applyPut(key, value)
override fun putLong(key: String?, value: Long): SharedPreferences.Editor = applyPut(key, value)
override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = applyPut(key, value)
override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = applyPut(key, value)
override fun remove(key: String?): SharedPreferences.Editor = applyPut(key, null)
override fun clear(): SharedPreferences.Editor {
clearRequested = true
return this
}
override fun commit(): Boolean {
flush()
return true
}
override fun apply() = flush()
private fun applyPut(key: String?, value: Any?): SharedPreferences.Editor {
if (key != null) pending[key] = value
return this
}
private fun flush() {
if (clearRequested) values.clear()
pending.forEach { (key, value) ->
if (value == null) values.remove(key) else values[key] = value
}
}
}
}
}

View file

@ -0,0 +1,155 @@
package com.aryan.reader
import com.aryan.reader.data.BookTagCrossRef
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf as SharedShelf
import com.aryan.reader.shared.ShelfType as SharedShelfType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertSame
import org.junit.Test
class SharedModelMappersTest {
@Test
fun `book mapper preserves android-only fields when shared projection maps back by id`() {
val tag = TagEntity(id = "tag", name = "Favorite", color = 0xFFAA00AA.toInt(), createdAt = 10L)
val original = recentFile(
id = "book",
type = FileType.PDF,
displayName = "Original.pdf",
customName = "Custom name",
isAvailable = false,
bookmarksJson = """[{"page":2}]""",
sourceFolderUri = "content://folder",
tags = listOf(tag)
)
val shared = original.toSharedBookItem()
val mapped = shared.toRecentFileItem(
androidBooksById = mapOf(original.bookId to original),
tagEntitiesById = mapOf(tag.id to tag)
)
assertEquals("Custom name", shared.displayName)
assertEquals(original.uriString, mapped.uriString)
assertEquals(original.displayName, mapped.displayName)
assertEquals(original.customName, mapped.customName)
assertEquals(original.bookmarksJson, mapped.bookmarksJson)
assertEquals(original.sourceFolderUri, mapped.sourceFolderUri)
assertFalse(mapped.isAvailable)
assertEquals(listOf(tag), mapped.tags)
}
@Test
fun `shared projection state maps shelves tabs selections and tags back to android state`() {
val tag = TagEntity(id = "tag", name = "Queued", createdAt = 1L)
val book = recentFile("book", tags = listOf(tag))
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),
selectedBookIds = setOf("book", "missing"),
selectedShelfIds = setOf("manual"),
shelves = listOf(sharedShelf),
openTabs = listOf(sharedBook),
openTabIds = listOf("book"),
activeTabBookId = "book",
booksAvailableForAdding = listOf(sharedBook),
allTags = listOf(tag.toSharedTag())
)
val android = projected.toAndroidReaderScreenState(
base = ReaderScreenState(contextualActionItems = setOf(recentFile("missing"))),
androidBooksById = mapOf(book.bookId to book),
tagEntitiesById = mapOf(tag.id to tag)
)
assertEquals(listOf("book"), android.recentFiles.ids())
assertEquals(listOf("book"), android.allRecentFiles.ids())
assertEquals(listOf("book"), android.rawLibraryFiles.ids())
assertEquals(setOf("book"), android.contextualActionItems.mapTo(mutableSetOf()) { it.bookId })
assertEquals(setOf("manual"), android.contextualActionShelfIds)
assertEquals(listOf("manual"), android.shelves.map { it.id })
assertEquals(listOf("book"), android.shelves.single().books.ids())
assertEquals(listOf("book"), android.openTabs.ids())
assertEquals(listOf("book"), android.openTabIds)
assertEquals("book", android.activeTabBookId)
assertEquals(listOf("book"), android.booksAvailableForAdding.ids())
assertEquals(listOf(tag), android.allTags)
}
@Test
fun `enum filter and folder mappers round trip between android and shared`() {
val filters = LibraryFilters(
fileTypes = setOf(FileType.PDF, FileType.EPUB),
sourceFolders = setOf("IN_APP_STORAGE", "content://folder"),
readStatus = ReadStatusFilter.IN_PROGRESS,
tagIds = setOf("tag")
)
val folder = SyncedFolder(
uriString = "content://folder",
name = "Folder",
lastScanTime = 42L,
allowedFileTypes = setOf(FileType.PDF, FileType.CBZ)
)
assertEquals(FileType.PDF, SharedFileType.PDF.toAndroidFileType())
assertEquals(SharedFileType.CBZ, FileType.CBZ.toSharedFileType())
assertSame(FileType.UNKNOWN, SharedFileType.UNKNOWN.toAndroidFileType())
assertSame(filters, filters.toSharedLibraryFilters())
assertSame(folder, folder.toSharedSyncedFolder())
assertEquals(filters, filters.toSharedLibraryFilters().toAndroidLibraryFilters())
assertEquals(folder, folder.toSharedSyncedFolder().toAndroidSyncedFolder())
assertFalse(FileType.UNKNOWN in ANDROID_READABLE_FILE_TYPES)
assertFalse(FileType.UNKNOWN in ANDROID_SYNCABLE_FILE_TYPES)
}
@Test
fun `tag resolver attaches database tags before shared projection`() {
val tag = TagEntity(id = "tag", name = "Reference", createdAt = 1L)
val files = listOf(recentFile("book"))
val tagged = files.withResolvedTags(
dbTags = listOf(tag),
tagRefs = listOf(BookTagCrossRef(bookId = "book", tagId = "tag"))
)
assertEquals(listOf(tag), tagged.single().tags)
}
private fun recentFile(
id: String,
type: FileType = FileType.EPUB,
displayName: String = "$id.${type.name.lowercase()}",
customName: String? = null,
isAvailable: Boolean = true,
bookmarksJson: String? = null,
sourceFolderUri: String? = null,
tags: List<TagEntity> = emptyList()
) = RecentFileItem(
bookId = id,
uriString = "content://$id",
type = type,
displayName = displayName,
timestamp = 1L,
isAvailable = isAvailable,
bookmarksJson = bookmarksJson,
sourceFolderUri = sourceFolderUri,
customName = customName,
tags = tags
)
private fun List<RecentFileItem>.ids() = map { it.bookId }
}

View file

@ -0,0 +1,21 @@
package com.aryan.reader.data
import com.aryan.reader.FileType
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class FileTypeConverterTest {
@Test
fun `room converter stores enum names and preserves unknown fallback`() {
val converter = FileTypeConverter()
assertEquals("PDF", converter.fromFileType(FileType.PDF))
assertEquals(FileType.PDF, converter.toFileType("PDF"))
assertEquals("UNKNOWN", converter.fromFileType(FileType.UNKNOWN))
assertEquals(FileType.UNKNOWN, converter.toFileType("UNKNOWN"))
assertNull(converter.fromFileType(null))
assertNull(converter.toFileType(null))
}
}

View file

@ -31,7 +31,15 @@ class FolderBookMetadataTest {
val decoded = FolderBookMetadata.fromJsonString(metadata.toJsonString())
assertEquals(metadata.copy(author = null, lastPage = null, locatorCharOffset = null), decoded)
assertEquals(
metadata.copy(
title = null,
author = null,
lastPage = null,
locatorCharOffset = null
),
decoded
)
}
@Test
@ -78,8 +86,8 @@ class FolderBookMetadataTest {
assertEquals("book-2", item.bookId)
assertEquals(FileType.EPUB, item.type)
assertEquals("Remote Title", item.title)
assertEquals("Author", item.author)
assertEquals("Remote", item.title)
assertNull(item.author)
assertEquals(12, item.lastPage)
assertEquals(7, item.locatorBlockIndex)
assertEquals(8, item.locatorCharOffset)
@ -87,4 +95,34 @@ class FolderBookMetadataTest {
assertEquals("Shelf Name", item.customName)
assertEquals("highlights", item.highlightsJson)
}
@Test
fun `toRecentFileItem preserves explicit unknown file type`() {
val metadata = FolderBookMetadata(
bookId = "book-3",
title = null,
author = null,
displayName = "Remote.bin",
type = "UNKNOWN",
lastChapterIndex = null,
lastPage = null,
lastPositionCfi = null,
progressPercentage = 0f,
isRecent = false,
lastModifiedTimestamp = 500L,
bookmarksJson = null,
locatorBlockIndex = null,
locatorCharOffset = null,
customName = null,
highlightsJson = null
)
val item = metadata.toRecentFileItem(
uriString = "content://book",
coverPath = null,
sourceFolderUri = "content://folder"
)
assertEquals(FileType.UNKNOWN, item.type)
}
}

View file

@ -0,0 +1,244 @@
package com.aryan.reader.data
import androidx.room.Room
import com.aryan.reader.FileType
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
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
@RunWith(RobolectricTestRunner::class)
class RecentFileDaoMetadataExtractionTest {
private lateinit var db: AppDatabase
private lateinit var dao: RecentFileDao
@Before
fun setUp() {
db = Room.inMemoryDatabaseBuilder(
RuntimeEnvironment.getApplication(),
AppDatabase::class.java
).allowMainThreadQueries().build()
dao = db.recentFileDao()
}
@After
fun tearDown() {
db.close()
}
@Test
fun `cover-only ebook metadata candidate is marked attempted after no cover is found`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity(
folderTextMetadataParsed = true,
folderCoverMetadataParsed = false
)
)
assertEquals(1, dao.countFolderBooksNeedingTextMetadata("content://folder"))
dao.updateExtractedMetadata(
bookId = "book-1",
coverImagePath = null,
title = null,
author = null,
seriesName = null,
seriesIndex = null,
description = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
textMetadataParsed = false,
coverMetadataParsed = true
)
val saved = dao.getFileByBookId("book-1")!!
assertFalse(saved.coverImagePath?.isNotBlank() == true)
assertTrue(saved.folderCoverMetadataParsed)
assertEquals(0, dao.countFolderBooksNeedingTextMetadata("content://folder"))
}
@Test
fun `metadata candidate query respects batch limit`() = runTest {
dao.insertOrUpdateFile(recentFileEntity(bookId = "book-1", timestamp = 1_000L))
dao.insertOrUpdateFile(recentFileEntity(bookId = "book-2", timestamp = 2_000L))
val pending = dao.getFolderBooksNeedingTextMetadata("content://folder", limit = 1)
assertEquals(1, pending.size)
assertEquals("book-2", pending.single().bookId)
}
@Test
fun `metadata extraction does not replace user edited metadata`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity().copy(
title = "Edited title",
author = "Edited author",
originalTitle = "Original title",
originalAuthor = "Original author"
)
)
dao.updateExtractedMetadata(
bookId = "book-1",
coverImagePath = null,
title = "Extracted title",
author = "Extracted author",
seriesName = null,
seriesIndex = null,
description = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
textMetadataParsed = true,
coverMetadataParsed = false
)
val saved = dao.getFileByBookId("book-1")!!
assertEquals("Edited title", saved.title)
assertEquals("Edited author", saved.author)
assertEquals("Original title", saved.originalTitle)
assertEquals("Original author", saved.originalAuthor)
}
@Test
fun `metadata extraction promotes extracted values before user edits`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity().copy(
title = "book-1.epub",
author = null,
originalTitle = "book-1.epub",
originalAuthor = null
)
)
dao.updateExtractedMetadata(
bookId = "book-1",
coverImagePath = null,
title = "Extracted title",
author = "Extracted author",
seriesName = null,
seriesIndex = null,
description = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
textMetadataParsed = true,
coverMetadataParsed = false
)
val saved = dao.getFileByBookId("book-1")!!
assertEquals("Extracted title", saved.title)
assertEquals("Extracted author", saved.author)
assertEquals("Extracted title", saved.originalTitle)
assertEquals("Extracted author", saved.originalAuthor)
}
@Test
fun `restore original metadata restores snapshot and clears display override`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity().copy(
title = "Edited title",
author = "Edited author",
seriesName = "Edited series",
seriesIndex = 2.0,
description = "Edited summary",
customName = "Edited display",
originalTitle = "Original title",
originalAuthor = "Original author",
originalSeriesName = "Original series",
originalSeriesIndex = 1.0,
originalDescription = "Original summary"
)
)
dao.restoreOriginalMetadata("book-1", fileSize = 0L, fileContentModifiedTimestamp = 0L, timestamp = 9_000L)
val saved = dao.getFileByBookId("book-1")!!
assertEquals("Original title", saved.title)
assertEquals("Original author", saved.author)
assertEquals("Original series", saved.seriesName)
assertEquals(1.0, saved.seriesIndex)
assertEquals("Original summary", saved.description)
assertNull(saved.customName)
assertEquals(9_000L, saved.lastModifiedTimestamp)
}
@Test
fun `manual metadata update seeds missing original snapshot from previous values`() = runTest {
dao.insertOrUpdateFile(
recentFileEntity().copy(
title = "Existing title",
author = "Existing author",
customName = "Display override",
originalTitle = null,
originalAuthor = null
)
)
dao.updateUserEditableMetadata(
bookId = "book-1",
title = "Edited title",
author = "Edited author",
seriesName = null,
seriesIndex = null,
description = null,
fileSize = 0L,
fileContentModifiedTimestamp = 0L,
timestamp = 5_000L
)
val saved = dao.getFileByBookId("book-1")!!
assertEquals("Edited title", saved.title)
assertEquals("Edited author", saved.author)
assertEquals("Existing title", saved.originalTitle)
assertEquals("Existing author", saved.originalAuthor)
assertNull(saved.customName)
}
private fun recentFileEntity(
bookId: String = "book-1",
timestamp: Long = 1_000L,
folderTextMetadataParsed: Boolean = false,
folderCoverMetadataParsed: Boolean = false
): RecentFileEntity {
return RecentFileEntity(
bookId = bookId,
uriString = "content://books/$bookId",
type = FileType.EPUB,
displayName = "$bookId.epub",
timestamp = timestamp,
coverImagePath = null,
title = "One",
author = "Author",
lastChapterIndex = null,
lastPage = null,
lastPositionCfi = null,
progressPercentage = null,
isRecent = false,
isAvailable = true,
lastModifiedTimestamp = timestamp,
isDeleted = false,
locatorBlockIndex = null,
locatorCharOffset = null,
bookmarks = null,
sourceFolderUri = "content://folder",
isReflowPreferred = false,
customName = null,
highlights = null,
fileSize = 123L,
fileContentModifiedTimestamp = 1_234L,
seriesName = null,
seriesIndex = null,
description = null,
folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = folderCoverMetadataParsed
)
}
}

View file

@ -17,6 +17,7 @@ class RecentFileItemReadingPositionMappingTest {
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
}
@Test
@ -30,6 +31,59 @@ class RecentFileItemReadingPositionMappingTest {
assertEquals(item.locatorBlockIndex, roundTripped.locatorBlockIndex)
assertEquals(item.locatorCharOffset, roundTripped.locatorCharOffset)
assertEquals(item.progressPercentage, roundTripped.progressPercentage)
assertEquals(item.fileContentModifiedTimestamp, roundTripped.fileContentModifiedTimestamp)
}
@Test
fun `cloud metadata mapping preserves non epub display rename as custom name`() {
val item = recentFileItem().copy(
type = FileType.PDF,
customName = "Reader Display Name"
)
val roundTripped = item.toBookMetadata().toRecentFileItem()
assertEquals("Reader Display Name", roundTripped.customName)
assertEquals("One.epub", roundTripped.displayName)
}
@Test
fun `recent file entity mapping preserves original metadata snapshot`() {
val item = recentFileItem().copy(
title = "Edited One",
author = "Edited Author",
seriesName = "Edited Series",
seriesIndex = 2.0,
description = "Edited summary",
originalTitle = "Original One",
originalAuthor = "Original Author",
originalSeriesName = "Original Series",
originalSeriesIndex = 1.0,
originalDescription = "Original summary"
)
val roundTripped = item.toRecentFileEntity().toRecentFileItem()
assertEquals("Original One", roundTripped.originalTitle)
assertEquals("Original Author", roundTripped.originalAuthor)
assertEquals("Original Series", roundTripped.originalSeriesName)
assertEquals(1.0, roundTripped.originalSeriesIndex)
assertEquals("Original summary", roundTripped.originalDescription)
}
@Test
fun `recent file entity mapping seeds original metadata when first stored`() {
val entity = recentFileItem().copy(
seriesName = "Series",
seriesIndex = 1.0,
description = "Summary"
).toRecentFileEntity()
assertEquals("One", entity.originalTitle)
assertEquals("Author", entity.originalAuthor)
assertEquals("Series", entity.originalSeriesName)
assertEquals(1.0, entity.originalSeriesIndex)
assertEquals("Summary", entity.originalDescription)
}
private fun recentFileItem(): RecentFileItem {
@ -47,6 +101,7 @@ class RecentFileItemReadingPositionMappingTest {
locatorCharOffset = 88,
progressPercentage = 61.5f,
lastModifiedTimestamp = 2_000L,
fileContentModifiedTimestamp = 3_000L,
bookmarksJson = """[{"cfi":"/4/2"}]""",
highlightsJson = """[{"cfi":"/4/2/6:88"}]"""
)

View file

@ -14,7 +14,9 @@ import io.mockk.unmockkObject
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Before
import org.junit.Test
import java.io.File
@ -113,14 +115,82 @@ class RecentFilesRepositoryReadingPositionMergeTest {
assertEquals(82f, inserted.captured.progressPercentage)
}
private fun existingEntity(): RecentFileEntity {
@Test
fun `addRecentFile keeps edited embedded epub metadata when cached parser returns original metadata`() = runTest {
val inserted = slot<RecentFileEntity>()
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity().copy(
author = "Edited Author",
originalAuthor = "Author",
fileContentModifiedTimestamp = 5_000L
)
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
repository.addRecentFile(
RecentFileItem(
bookId = "book-1",
uriString = "content://new",
type = FileType.EPUB,
displayName = "New.epub",
timestamp = 2_000L,
title = "Old",
author = "Author",
fileContentModifiedTimestamp = 5_000L,
isRecent = true
)
)
assertEquals("Edited Author", inserted.captured.author)
assertEquals("Author", inserted.captured.originalAuthor)
}
@Test
fun `addRecentFile clears extracted metadata when folder file size changes`() = runTest {
val inserted = slot<RecentFileEntity>()
coEvery { recentFileDao.getFileByBookId("book-1") } returns existingEntity(
fileSize = 123L,
folderTextMetadataParsed = true,
folderCoverMetadataParsed = true,
coverImagePath = "/covers/old.png"
)
coEvery { recentFileDao.insertOrUpdateFile(capture(inserted)) } just Runs
repository.addRecentFile(
RecentFileItem(
bookId = "book-1",
uriString = "content://new",
type = FileType.EPUB,
displayName = "New.epub",
timestamp = 2_000L,
sourceFolderUri = "content://folder",
fileSize = 456L,
isRecent = true
)
)
assertEquals(456L, inserted.captured.fileSize)
assertNull(inserted.captured.coverImagePath)
assertEquals("New", inserted.captured.title)
assertNull(inserted.captured.author)
assertNull(inserted.captured.seriesName)
assertNull(inserted.captured.description)
assertNull(inserted.captured.originalTitle)
assertFalse(inserted.captured.folderTextMetadataParsed)
assertFalse(inserted.captured.folderCoverMetadataParsed)
}
private fun existingEntity(
fileSize: Long = 123L,
folderTextMetadataParsed: Boolean = true,
folderCoverMetadataParsed: Boolean = false,
coverImagePath: String? = "/covers/old.png"
): RecentFileEntity {
return RecentFileEntity(
bookId = "book-1",
uriString = "content://old",
type = FileType.EPUB,
displayName = "Old.epub",
timestamp = 1_000L,
coverImagePath = "/covers/old.png",
coverImagePath = coverImagePath,
title = "Old",
author = "Author",
lastChapterIndex = 6,
@ -138,11 +208,12 @@ class RecentFilesRepositoryReadingPositionMergeTest {
isReflowPreferred = false,
customName = "Custom",
highlights = "highlights",
fileSize = 123L,
fileSize = fileSize,
seriesName = "Series",
seriesIndex = 1.0,
description = "Description",
folderTextMetadataParsed = true
folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = folderCoverMetadataParsed
)
}
}

View file

@ -15,6 +15,7 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
class EpubParserUnitTest {
@ -103,6 +104,43 @@ class EpubParserUnitTest {
assertTrue(extractionDir.list().isNullOrEmpty())
}
@Test
fun `metadata only extraction streams images to disk without retaining image bytes`() {
val cacheDir = temp.newFolder("cache-metadata-stream")
val extractionDir = temp.newFolder("extract-metadata-stream")
val parser = EpubParser(contextWithCache(cacheDir))
val imageBytes = ByteArray(2 * 1024 * 1024) { 7 }
val zipFileOnDisk = File(temp.root, "metadata-stream.epub")
zipFileOnDisk.writeBytes(
zipBinaryBytes(
"META-INF/container.xml" to """
<container><rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles></container>
""".trimIndent().toByteArray(Charsets.UTF_8),
"OEBPS/content.opf" to """
<package>
<metadata />
<manifest>
<item id="cover" href="images/cover.jpg" media-type="image/jpeg"/>
</manifest>
<spine />
</package>
""".trimIndent().toByteArray(Charsets.UTF_8),
"OEBPS/images/cover.jpg" to imageBytes
)
)
val files = parser.extractEpubContents(
zipFile = ZipFile(zipFileOnDisk),
extractionDir = extractionDir,
parseContent = false,
extractImagesForMetadata = true
)
assertTrue(files["META-INF/container.xml"]!!.data.isNotEmpty())
assertEquals(0, files["OEBPS/images/cover.jpg"]!!.data.size)
assertEquals(imageBytes.size.toLong(), File(extractionDir, "OEBPS/images/cover.jpg").length())
}
@Test
fun `createEpubBook reuses active extraction cache on matching warm open`() = runTest {
val cacheDir = temp.newFolder("cache-warm-open")
@ -129,6 +167,35 @@ class EpubParserUnitTest {
assertTrue(File(activeDir, "sentinel.txt").isFile)
}
@Test
fun `createEpubBook invalidates active extraction cache when source fingerprint changes`() = runTest {
val cacheDir = temp.newFolder("cache-source-change")
val context = contextWithCache(cacheDir)
val parser = EpubParser(context)
val first = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes()),
bookId = "changed-book",
shouldUseToc = true,
originalBookNameHint = "changed.epub",
sourceFingerprint = "100:1000"
)
val activeDir = ImportedFileCache.activeBookDir(context, "changed-book")
File(activeDir, "sentinel.txt").writeText("old extraction")
val second = parser.createEpubBook(
inputStream = ByteArrayInputStream(sampleEpubBytes(author = "Edited Writer")),
bookId = "changed-book",
shouldUseToc = true,
originalBookNameHint = "changed.epub",
sourceFingerprint = "120:2000"
)
assertEquals("Jane Writer", first.author)
assertEquals("Edited Writer", second.author)
assertFalse(File(activeDir, "sentinel.txt").isFile)
}
@Test
fun `metadata only parse does not clear active extracted content`() = runTest {
val cacheDir = temp.newFolder("cache-metadata-preserve")
@ -325,7 +392,7 @@ class EpubParserUnitTest {
return context
}
private fun sampleEpubBytes(): ByteArray = zipBytes(
private fun sampleEpubBytes(author: String = "Jane Writer"): ByteArray = zipBytes(
"META-INF/container.xml" to """
<container version="1.0">
<rootfiles><rootfile full-path="OEBPS/content.opf"/></rootfiles>
@ -335,7 +402,7 @@ class EpubParserUnitTest {
<package xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata>
<dc:title>Sample/Book</dc:title>
<dc:creator>Jane Writer</dc:creator>
<dc:creator>$author</dc:creator>
<dc:language>en</dc:language>
<dc:description>Long description</dc:description>
<meta name="calibre:series" content="Series Name"/>
@ -415,11 +482,15 @@ class EpubParserUnitTest {
)
private fun zipBytes(vararg entries: Pair<String, String>): ByteArray {
return zipBinaryBytes(*entries.map { it.first to it.second.toByteArray(Charsets.UTF_8) }.toTypedArray())
}
private fun zipBinaryBytes(vararg entries: Pair<String, ByteArray>): ByteArray {
val out = ByteArrayOutputStream()
ZipOutputStream(out).use { zip ->
entries.forEach { (name, content) ->
zip.putNextEntry(ZipEntry(name))
zip.write(content.toByteArray(Charsets.UTF_8))
zip.write(content)
zip.closeEntry()
}
}

View file

@ -196,5 +196,6 @@ class EpubReaderBridgeAndControlsTest {
assertTrue(ReaderTool.entries.any { it.category == "Top Bar" })
assertTrue(ReaderTool.entries.any { it.category == "Bottom Bar" })
assertTrue(ReaderTool.entries.any { it.category == "Overflow Menu" })
assertEquals("Top Bar", ReaderTool.SCREEN_ORIENTATION.category)
}
}

View file

@ -188,6 +188,10 @@ class OpdsParserTest {
val acquisitions = listOf(
OpdsAcquisition("txt", "text/plain"),
OpdsAcquisition("pdf", "application/pdf"),
OpdsAcquisition(
"pptx",
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
),
OpdsAcquisition("epub", "application/epub+zip"),
OpdsAcquisition("unknown", "application/octet-stream")
)
@ -200,9 +204,10 @@ class OpdsParserTest {
navigationUrl = null
)
assertEquals("EPUB", acquisitions[2].formatName)
assertEquals("EPUB", acquisitions[3].formatName)
assertEquals("PPTX", acquisitions[2].formatName)
assertEquals("TXT", acquisitions[0].formatName)
assertEquals("OCTET-STREAM", acquisitions[3].formatName)
assertEquals(acquisitions[2], entry.bestAcquisition)
assertEquals("OCTET-STREAM", acquisitions[4].formatName)
assertEquals(acquisitions[3], entry.bestAcquisition)
}
}

View file

@ -1,6 +1,8 @@
package com.aryan.reader.paginatedreader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@ -20,6 +22,14 @@ class CfiUtilsTest {
assertEquals(0, CfiUtils.getOffset("/4/2/6:bad"))
}
@Test
fun `getOffsetOrNull only returns explicit numeric offsets`() {
assertEquals(13, CfiUtils.getOffsetOrNull("/4/2/6:13"))
assertEquals(0, CfiUtils.getOffsetOrNull("/4/2/6:0"))
assertNull(CfiUtils.getOffsetOrNull("/4/2/6"))
assertNull(CfiUtils.getOffsetOrNull("/4/2/6:bad"))
}
@Test
fun `compare sorts numeric cfi paths before character offsets`() {
assertTrue(CfiUtils.compare("/4/2", "/4/10") < 0)
@ -27,4 +37,12 @@ class CfiUtilsTest {
assertTrue(CfiUtils.compare("/4/2/6:7", "/4/2/6:18") < 0)
assertEquals(0, CfiUtils.compare("/4/2/6:bad", "/4/2/6"))
}
@Test
fun `isPathStrictlyBetween requires a bounded numeric cfi path`() {
assertTrue(CfiUtils.isPathStrictlyBetween("/4/4", "/4/2:1", "/4/6:1"))
assertFalse(CfiUtils.isPathStrictlyBetween("/4/2", "/4/2:1", "/4/6:1"))
assertFalse(CfiUtils.isPathStrictlyBetween("/4/8", "/4/2:1", "/4/6:1"))
assertFalse(CfiUtils.isPathStrictlyBetween("/4/nav", "/4/2:1", "/4/6:1"))
}
}

View file

@ -1,9 +1,12 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.sp
import org.junit.Assert.assertEquals
@ -123,6 +126,47 @@ class ContentStylerTest {
assertEquals("li1", first.elementId)
assertEquals("https://example.org", first.content.getStringAnnotations("URL", 0, 5).single().item)
assertEquals("link", first.content.getStringAnnotations("ID", 0, 5).single().item)
assertTrue(first.content.spanStyles.any { range ->
range.start <= 0 &&
range.end >= 5 &&
range.item.color.isSpecified &&
range.item.color != Color.Red &&
range.item.background.isSpecified &&
range.item.textDecoration?.contains(TextDecoration.Underline) == true
})
}
@Test
fun `runtime theme reapplies visible link style for cached paginated text`() {
val linkText = "Cached link"
val text = buildAnnotatedString {
append(linkText)
addStringAnnotation("URL", "https://example.org", 0, linkText.length)
}
val page = Page(
content = listOf(
ParagraphBlock(
content = text,
blockIndex = 1
)
)
)
val themed = page.applyReaderThemeForDisplay(
isDarkTheme = true,
themeBackgroundColor = Color(0xFF121212),
themeTextColor = Color(0xFFE0E0E0)
)
val paragraph = themed.content.single() as ParagraphBlock
assertTrue(paragraph.content.spanStyles.any { range ->
range.start == 0 &&
range.end == linkText.length &&
range.item.color.isSpecified &&
range.item.color != Color(0xFFE0E0E0) &&
range.item.background.isSpecified &&
range.item.textDecoration?.contains(TextDecoration.Underline) == true
})
}
@Test

View file

@ -0,0 +1,83 @@
package com.aryan.reader.paginatedreader
import androidx.compose.ui.text.AnnotatedString
import com.aryan.reader.epubreader.HighlightColor
import com.aryan.reader.epubreader.UserHighlight
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class PaginatedHighlightMappingTest {
@Test
fun `single cfi highlight does not leak onto later matching block`() {
val block = paragraph(
text = "repeat",
cfi = "/4/4",
startOffset = 20
)
val highlight = highlight(
cfi = "/4/2:0",
text = "repeat"
)
assertNull(getHighlightOffsetsInBlock(block, highlight))
}
@Test
fun `multipart highlight can fill strict intermediate block`() {
val block = paragraph(
text = "middle",
cfi = "/4/4",
startOffset = 20
)
val highlight = highlight(
cfi = "/4/2:0|/4/6:10",
text = "start middle end"
)
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:0|/4/2:6",
text = "repeat"
)
assertNull(getHighlightOffsetsInBlock(block, highlight))
}
private fun paragraph(
text: String,
cfi: String,
startOffset: Int
): ParagraphBlock {
return ParagraphBlock(
content = AnnotatedString(text),
cfi = cfi,
startCharOffsetInSource = startOffset,
endCharOffsetInSource = startOffset + text.length,
blockIndex = startOffset
)
}
private fun highlight(
cfi: String,
text: String
): UserHighlight {
return UserHighlight(
id = "highlight",
cfi = cfi,
text = text,
color = HighlightColor.YELLOW,
chapterIndex = 0
)
}
}

View file

@ -0,0 +1,97 @@
package com.aryan.reader.paginatedreader
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class StablePaginatedNavigationTest {
@Test
fun `chapter zero needs no prefix stabilization`() = runTest {
val requested = mutableListOf<Int>()
val startPage = resolveStableChapterStartPage(
chapterIndex = 0,
chapterCount = 4,
pageCountsAreAccurate = false,
chapterStartPage = { chapterStarts[it] },
isChapterFinalized = { false },
ensureChapterPaginated = {
requested += it
true
}
)
assertEquals(0, startPage)
assertTrue(requested.isEmpty())
}
@Test
fun `target skips finalized prefix chapters`() = runTest {
val requested = mutableListOf<Int>()
val startPage = resolveStableChapterStartPage(
chapterIndex = 3,
chapterCount = 5,
pageCountsAreAccurate = false,
chapterStartPage = { chapterStarts[it] },
isChapterFinalized = { it == 0 || it == 2 },
ensureChapterPaginated = {
requested += it
true
}
)
assertEquals(45, startPage)
assertEquals(listOf(1), requested)
}
@Test
fun `accurate cached page counts skip prefix pagination`() = runTest {
val requested = mutableListOf<Int>()
val startPage = resolveStableChapterStartPage(
chapterIndex = 4,
chapterCount = 5,
pageCountsAreAccurate = true,
chapterStartPage = { chapterStarts[it] },
isChapterFinalized = { false },
ensureChapterPaginated = {
requested += it
true
}
)
assertEquals(60, startPage)
assertTrue(requested.isEmpty())
}
@Test
fun `missing prefix chapters are requested in order`() = runTest {
val requested = mutableListOf<Int>()
val startPage = resolveStableChapterStartPage(
chapterIndex = 4,
chapterCount = 5,
pageCountsAreAccurate = false,
chapterStartPage = { chapterStarts[it] },
isChapterFinalized = { false },
ensureChapterPaginated = {
requested += it
true
}
)
assertEquals(60, startPage)
assertEquals(listOf(0, 1, 2, 3), requested)
}
private val chapterStarts = mapOf(
0 to 0,
1 to 10,
2 to 25,
3 to 45,
4 to 60
)
}

View file

@ -0,0 +1,99 @@
package com.aryan.reader.pdf
import android.graphics.Rect
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class MagnifierGeometryTest {
@Test
fun `base bitmap sample maps displayed page coordinates into rendered source pixels`() {
val source = MagnifierContentSource(
sourceWidth = 300,
sourceHeight = 600,
contentLeft = 0f,
contentTop = 0f,
contentWidth = 200f,
contentHeight = 400f
)
val sample = requireNotNull(
calculateMagnifierSampleGeometry(
centerContentX = 50f,
centerContentY = 200f,
contentSource = source,
magnifierWidthPx = 120f,
magnifierHeightPx = 60f,
zoomFactor = 2f
)
)
assertEquals(30, sample.srcLeft)
assertEquals(278, sample.srcTop)
assertEquals(90, sample.srcWidth)
assertEquals(45, sample.srcHeight)
}
@Test
fun `selection rect uses same rendered source transform as magnifier crop`() {
val source = MagnifierContentSource(
sourceWidth = 300,
sourceHeight = 600,
contentLeft = 0f,
contentTop = 0f,
contentWidth = 200f,
contentHeight = 400f
)
val sample = requireNotNull(
calculateMagnifierSampleGeometry(
centerContentX = 50f,
centerContentY = 200f,
contentSource = source,
magnifierWidthPx = 120f,
magnifierHeightPx = 60f,
zoomFactor = 2f
)
)
val mapped = mapContentRectToMagnifier(
contentRect = Rect(40, 190, 70, 210),
contentSource = source,
sample = sample
)
assertEquals(40f, mapped.left, 0.01f)
assertEquals(100f, mapped.right, 0.01f)
assertEquals(29.33f, mapped.centerY(), 0.05f)
}
@Test
fun `tile sample uses tile local source scale`() {
val source = MagnifierContentSource(
sourceWidth = 512,
sourceHeight = 512,
contentLeft = 100f,
contentTop = 200f,
contentWidth = 256f,
contentHeight = 256f
)
val sample = requireNotNull(
calculateMagnifierSampleGeometry(
centerContentX = 228f,
centerContentY = 328f,
contentSource = source,
magnifierWidthPx = 120f,
magnifierHeightPx = 60f,
zoomFactor = 2f
)
)
assertEquals(196, sample.srcLeft)
assertEquals(226, sample.srcTop)
assertEquals(120, sample.srcWidth)
assertEquals(60, sample.srcHeight)
}
}

View file

@ -0,0 +1,39 @@
package com.aryan.reader.pdf
import androidx.core.graphics.createBitmap
import org.junit.After
import org.junit.Assert.assertFalse
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class PdfBitmapPoolTest {
@After
fun tearDown() {
PdfBitmapPool.clear()
}
@Test
fun `recycle leaves overflow bitmaps valid for render thread handoff`() {
PdfBitmapPool.clear()
val bitmaps = List(6) { createBitmap(8, 8) }
bitmaps.forEach(PdfBitmapPool::recycle)
bitmaps.forEach { bitmap ->
assertFalse(bitmap.isRecycled)
}
}
@Test
fun `clear drops pooled bitmaps without invalidating external references`() {
val bitmap = createBitmap(8, 8)
PdfBitmapPool.recycle(bitmap)
PdfBitmapPool.clear()
assertFalse(bitmap.isRecycled)
}
}

View file

@ -1,7 +1,12 @@
package com.aryan.reader.pdf
import android.content.Context
import android.graphics.RectF
import android.graphics.Rect
import androidx.compose.ui.graphics.Color
import com.aryan.reader.pdf.data.PdfAnnotation
import com.aryan.reader.pdf.data.PdfAnnotationRepository
import com.aryan.reader.pdf.data.VirtualPage
import com.aryan.reader.pdf.ocr.OcrBlock
import com.aryan.reader.pdf.ocr.OcrElement
import com.aryan.reader.pdf.ocr.OcrLine
@ -13,6 +18,7 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
@RunWith(RobolectricTestRunner::class)
class PdfReaderCoreLogicTest {
@ -49,6 +55,40 @@ class PdfReaderCoreLogicTest {
)
}
@Test
fun `locked orientation reset camera returns base fit zoom and target page pan`() {
val camera = calculateLockedOrientationResetCamera(
pageTopY = 1_000f,
totalDocHeight = 3_000f,
screenWidth = 800f,
screenHeight = 1_200f,
headerHeightPx = 40f,
footerHeightPx = 60f,
fitZoom = 1f
)
assertEquals(1f, camera.zoom, 0.0001f)
assertEquals(0f, camera.panX, 0.0001f)
assertEquals(-960f, camera.panY, 0.0001f)
}
@Test
fun `locked orientation reset camera centers narrow fit zoom and clamps short documents`() {
val camera = calculateLockedOrientationResetCamera(
pageTopY = 120f,
totalDocHeight = 500f,
screenWidth = 1_000f,
screenHeight = 900f,
headerHeightPx = 40f,
footerHeightPx = 60f,
fitZoom = 0.5f
)
assertEquals(0.5f, camera.zoom, 0.0001f)
assertEquals(250f, camera.panX, 0.0001f)
assertEquals(40f, camera.panY, 0.0001f)
}
@Test
fun `getSuggestedFilename sanitizes truncates and marks annotated copies`() {
val filename = getSuggestedFilename(
@ -67,6 +107,66 @@ class PdfReaderCoreLogicTest {
assertTrue(filename, filename.matches(Regex("Document_\\d{4}\\.pdf")))
}
@Test
fun `pdfRenderPageId separates same page across documents`() {
val firstDocumentPage = pdfRenderPageId("book-a", 0, VirtualPage.PdfPage(0))
val secondDocumentPage = pdfRenderPageId("book-b", 0, VirtualPage.PdfPage(0))
assertEquals("book-a:PDF_0", firstDocumentPage)
assertTrue(firstDocumentPage != secondDocumentPage)
}
@Test
fun `pdfRenderPageId preserves virtual page source identity`() {
assertEquals("book:PDF_12", pdfRenderPageId("book", 3, VirtualPage.PdfPage(12)))
assertEquals(
"book:BLANK_blank-1",
pdfRenderPageId("book", 3, VirtualPage.BlankPage("blank-1", 595, 842))
)
}
@Test
fun `bubble prefetch only includes current page and nearby pages`() {
assertEquals(listOf(10, 11, 9), buildPdfBubblePrefetchOrder(currentPage = 10, totalPages = 100))
}
@Test
fun `bubble prefetch clamps current page and respects edges`() {
assertEquals(listOf(0, 1), buildPdfBubblePrefetchOrder(currentPage = -4, totalPages = 5))
assertEquals(listOf(4, 3), buildPdfBubblePrefetchOrder(currentPage = 99, totalPages = 5))
assertEquals(emptyList<Int>(), buildPdfBubblePrefetchOrder(currentPage = 0, totalPages = 0))
}
@Test
fun `canUsePdfSidecarsForBook only accepts loaded sidecars for active book`() {
assertTrue(canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = true))
assertEquals(false, canUsePdfSidecarsForBook("book-a", "book-b", areSidecarsLoaded = true))
assertEquals(false, canUsePdfSidecarsForBook("book-a", "book-a", areSidecarsLoaded = false))
assertEquals(false, canUsePdfSidecarsForBook(null, "book-a", areSidecarsLoaded = true))
}
@Test
fun `saveAnnotations deletes stored annotations when saving empty map`() = runTest {
val context: Context = RuntimeEnvironment.getApplication()
val repository = PdfAnnotationRepository(context)
val bookId = "empty-annotation-save-${System.nanoTime()}"
val annotation = PdfAnnotation(
type = AnnotationType.INK,
inkType = InkType.PEN,
pageIndex = 0,
points = listOf(PdfPoint(0.1f, 0.2f)),
color = Color.Black,
strokeWidth = 0.01f
)
repository.saveAnnotations(bookId, mapOf(0 to listOf(annotation)))
assertEquals(1, repository.loadAnnotations(bookId)[0]?.size)
repository.saveAnnotations(bookId, emptyMap())
assertEquals(emptyMap<Int, List<PdfAnnotation>>(), repository.loadAnnotations(bookId))
}
@Test
fun `preprocessTextForTts returns empty processed text for blank input`() {
val processed = preprocessTextForTts(" \n\t ")

View file

@ -31,7 +31,10 @@ class PdfReaderPreferencesTest {
assertEquals(PdfReaderTool.entries.size, order.size)
assertEquals(PdfReaderTool.entries.toSet(), order.toSet())
assertEquals(setOf(PdfReaderTool.SEARCH.name, PdfReaderTool.TOC.name), loadPdfBottomTools(context))
assertEquals(setOf(PdfReaderTool.PRINT.name), loadPdfHiddenTools(context))
assertEquals(
setOf(PdfReaderTool.PRINT.name, PdfReaderTool.SCREEN_ORIENTATION.name, PdfReaderTool.HIGHLIGHT_ALL.name),
loadPdfHiddenTools(context)
)
}
@Test
@ -44,6 +47,8 @@ class PdfReaderPreferencesTest {
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))
assertEquals(setOf(PdfReaderTool.SEARCH.name), loadPdfBottomTools(context))
assertEquals(listOf(PdfReaderTool.TOC, PdfReaderTool.SEARCH), loadPdfToolOrder(context).take(2))
}

View file

@ -33,7 +33,9 @@ class PdfReaderSerializerTest {
PdfPoint(0.2f, 0.3f, 11L)
),
color = Color(0xFF336699),
strokeWidth = 0.0125f
strokeWidth = 0.0125f,
id = "ink-1",
note = "Desktop note"
)
),
2 to listOf(
@ -53,6 +55,8 @@ class PdfReaderSerializerTest {
assertEquals(setOf(0, 2), decoded.keys)
val first = decoded.getValue(0).single()
assertEquals(AnnotationType.INK, first.type)
assertEquals("ink-1", first.id)
assertEquals("Desktop note", first.note)
assertEquals(InkType.FOUNTAIN_PEN, first.inkType)
assertEquals(Color(0xFF336699).toArgb(), first.color.toArgb())
assertEquals(0.0125f, first.strokeWidth, 0.00001f)
@ -81,6 +85,7 @@ class PdfReaderSerializerTest {
assertEquals(AnnotationType.INK, decoded.type)
assertEquals(InkType.PENCIL, decoded.inkType)
assertTrue(decoded.id.isNotBlank())
assertEquals(0L, decoded.points.single().timestamp)
assertTrue(AnnotationSerializer.fromJson("not json").isEmpty())
assertTrue(AnnotationSerializer.fromJson("").isEmpty())

View file

@ -14,6 +14,7 @@ import com.aryan.reader.shared.pdf.PdfZoomSpec
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
import com.aryan.reader.shared.pdf.SharedPdfAnnotationDefaults
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSerializer
import com.aryan.reader.shared.pdf.SharedPdfHighlighterPalette
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
@ -128,6 +129,21 @@ class PdfReaderSettingsAndSharedModelsTest {
assertTrue(highlighter.strokeWidth > pen.strokeWidth)
}
@Test
fun `SharedPdfHighlighterPalette preserves slots and normalizes alpha`() {
val palette = SharedPdfHighlighterPalette(
colors = listOf(0xFFFF0000.toInt())
).sanitized()
assertEquals(SharedPdfHighlighterPalette.MaxColors, palette.colors.size)
assertEquals(0x8CFF0000.toInt(), palette.colors.first())
assertTrue(palette.colors.all { (it ushr 24) == SharedPdfHighlighterPalette.DefaultAlpha })
val updated = palette.withColorAt(2, 0xFF123456.toInt())
assertEquals(0x8C123456.toInt(), updated.colors[2])
}
@Test
fun `PdfZoomSpec clamps scale and keeps render size under pixel budget`() {
val spec = PdfZoomSpec(min = 0.5f, max = 4f, default = 1f, maxRenderPixels = 1_000_000)

View file

@ -4,6 +4,7 @@ import android.content.Context
import com.aryan.reader.SearchResult
import com.aryan.reader.pdf.data.PdfMetaDao
import com.aryan.reader.pdf.data.PdfMetadata
import com.aryan.reader.pdf.data.PdfSearchIndex
import com.aryan.reader.pdf.data.PdfSearchMatch
import com.aryan.reader.pdf.data.PdfTextDao
import com.aryan.reader.pdf.data.PdfTextDatabase
@ -11,6 +12,7 @@ import com.aryan.reader.pdf.data.PdfTextRepository
import com.aryan.reader.pdf.data.SmartSearchResult
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
@ -105,6 +107,29 @@ class PdfTextRepositoryTest {
assertEquals("needle", matches[0].query)
}
@Test
fun `indexReaderPage replaces existing page text before inserting new text`() = runTest {
val document = mockk<ReaderDocument>()
val page = mockk<ReaderPage>(relaxed = true)
val textPage = mockk<ReaderTextPage>(relaxed = true)
coEvery { document.openPage(0) } returns page
coEvery { page.openTextPage() } returns textPage
coEvery { textPage.textPageCountChars() } returns 11
coEvery { textPage.textPageGetText(0, 11) } returns "hello world"
val insertedPageText = slot<PdfSearchIndex>()
repository.indexReaderPage("book", document, 0)
coVerifyOrder {
dao.deletePageText("book", 0)
dao.insertPageText(capture(insertedPageText))
}
assertEquals("book", insertedPageText.captured.bookId)
assertEquals(0, insertedPageText.captured.pageIndex)
assertEquals("hello world", insertedPageText.captured.content)
}
@Test
fun `smart search emits paged result when page match count is large`() = runTest {
coEvery { dao.countMatches("book", "content:common*") } returns 51

View file

@ -0,0 +1,131 @@
package com.aryan.reader.pdf
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class PdfZoomLockStateTest {
@Test
fun `paginated locked page waits to report camera until saved lock is applied`() {
val lockedState = Triple(2.25f, -12f, 32f)
assertFalse(
shouldReportPdfPageCamera(
isZoomEnabled = true,
isVerticalScroll = false,
isScrollLocked = true,
lockedState = lockedState,
hasAppliedLockedState = false
)
)
assertTrue(
shouldReportPdfPageCamera(
isZoomEnabled = true,
isVerticalScroll = false,
isScrollLocked = true,
lockedState = lockedState,
hasAppliedLockedState = true
)
)
}
@Test
fun `paginated locked page initializes from saved camera`() {
val camera = initialPdfPageCamera(
isZoomEnabled = true,
isVerticalScroll = false,
isScrollLocked = true,
lockedState = Triple(2.25f, -12f, 32f)
)
assertEquals(2.25f, camera.first, 0.0001f)
assertEquals(-12f, camera.second.x, 0.0001f)
assertEquals(32f, camera.second.y, 0.0001f)
}
@Test
fun `loading locked preferences primes active camera from saved state`() {
val camera = activePdfCameraAfterLockPreferenceLoad(
isScrollLocked = true,
lockedState = Triple(2.25f, -12f, 32f)
)
assertEquals(2.25f, camera.first, 0.0001f)
assertEquals(-12f, camera.second.x, 0.0001f)
assertEquals(32f, camera.second.y, 0.0001f)
}
@Test
fun `paginated locked page can report camera when no saved lock exists yet`() {
assertTrue(
shouldReportPdfPageCamera(
isZoomEnabled = true,
isVerticalScroll = false,
isScrollLocked = true,
lockedState = null,
hasAppliedLockedState = false
)
)
}
@Test
fun `bubble zoom cleanup does not reset zoom while scroll lock is on`() {
assertFalse(
shouldResetPdfZoomAfterBubbleZoomCleanup(
isBubbleZoomModeActive = false,
scale = 1.8f,
isVerticalScroll = false,
isZoomEnabled = true,
isScrollLocked = true
)
)
assertTrue(
shouldResetPdfZoomAfterBubbleZoomCleanup(
isBubbleZoomModeActive = false,
scale = 1.8f,
isVerticalScroll = false,
isZoomEnabled = true,
isScrollLocked = false
)
)
}
@Test
fun `page change preserves locked zoom scale only in paginated lock mode`() {
val lockedState = Triple(2.25f, -12f, 32f)
assertEquals(
2.25f,
currentPageScaleAfterPdfPageChange(
displayMode = DisplayMode.PAGINATION,
isScrollLocked = true,
lockedState = lockedState,
currentActiveScale = 1f
),
0.0001f
)
assertEquals(
1f,
currentPageScaleAfterPdfPageChange(
displayMode = DisplayMode.PAGINATION,
isScrollLocked = false,
lockedState = lockedState,
currentActiveScale = 2.25f
),
0.0001f
)
assertEquals(
1f,
currentPageScaleAfterPdfPageChange(
displayMode = DisplayMode.VERTICAL_SCROLL,
isScrollLocked = true,
lockedState = lockedState,
currentActiveScale = 2.25f
),
0.0001f
)
}
}

View file

@ -0,0 +1,312 @@
package com.aryan.reader.pptx
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import com.aryan.reader.FileType
import com.aryan.reader.pdf.DocumentFactory
import io.legere.pdfiumandroid.suspend.PdfiumCoreKt
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import java.io.File
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
@RunWith(RobolectricTestRunner::class)
class PptxDocumentParserTest {
@Test
fun `parser resolves slide order inheritance and media relationships`() {
val file = createTinyPptx()
val deck = PptxDocumentParser.parse(file)
assertEquals(720, deck.widthPoint)
assertEquals(405, deck.heightPoint)
assertEquals(1, deck.slides.size)
assertTrue(deck.slides.single().text.contains("Master Text"))
assertTrue(deck.slides.single().text.contains("Layout Text"))
assertTrue(deck.slides.single().text.contains("Hello PPTX"))
assertTrue(deck.slides.single().text.contains("Inherited Placeholder"))
assertTrue(deck.slides.single().text.contains("Cell A"))
assertTrue(deck.slides.single().text.contains("Grouped Text"))
assertTrue(deck.slides.single().text.contains("1. First item"))
assertTrue(deck.slides.single().text.contains("2. Second item"))
assertTrue(deck.slides.single().text.contains("\u2022 Wingding bullet"))
assertFalse(deck.slides.single().text.contains("Layout Placeholder Prompt"))
val inheritedPlaceholder = deck.slides.single().elements
.filterIsInstance<PptxShapeElement>()
.single { shape -> shape.paragraphs.any { paragraph -> paragraph.runs.any { it.text.contains("Inherited Placeholder") } } }
assertEquals(PptxTextAlign.CENTER, inheritedPlaceholder.paragraphs.single().alignment)
assertEquals(24f, inheritedPlaceholder.paragraphs.single().runs.first().sizePt)
assertEquals(PptxAutoFitMode.NORMAL, inheritedPlaceholder.autoFitMode)
assertEquals(0.8f, inheritedPlaceholder.fontScale, 0.001f)
val centeredShape = deck.slides.single().elements
.filterIsInstance<PptxShapeElement>()
.single { shape -> shape.paragraphs.any { paragraph -> paragraph.runs.any { it.text.contains("Centered") } } }
assertEquals(PptxTextAlign.CENTER, centeredShape.paragraphs.single().alignment)
assertEquals(PptxVerticalAnchor.MIDDLE, centeredShape.verticalAnchor)
assertEquals(36f, centeredShape.paragraphs.single().runs.first().sizePt)
val table = deck.slides.single().elements.filterIsInstance<PptxTableElement>().single()
assertEquals(1, table.rows.size)
assertEquals(PptxVerticalAnchor.MIDDLE, table.rows.single().cells.first().verticalAnchor)
assertTrue(table.rows.single().cells[1].fillColor != null)
assertTrue(table.rows.single().cells[1].lineColor != null)
val groupedShape = deck.slides.single().elements
.filterIsInstance<PptxShapeElement>()
.single { shape -> shape.paragraphs.any { paragraph -> paragraph.runs.any { it.text.contains("Grouped Text") } } }
assertTrue(groupedShape.bounds.left > 35f)
assertEquals(PptxAutoFitMode.SHAPE, groupedShape.autoFitMode)
val image = deck.slides.single().elements.filterIsInstance<PptxImageElement>().single()
assertTrue(image.bytes.contentEquals(byteArrayOf(1, 2, 3, 4)))
assertTrue(image.crop.left > 0f)
assertEquals(0.35f, image.opacity, 0.001f)
assertTrue(deck.slides.single().elements.filterIsInstance<PptxShapeElement>().any { it.customGeometry != null })
}
@Test
fun `document wrapper exposes page geometry and indexed text`() = runTest {
val file = createTinyPptx()
PptxDocumentWrapper(file).use { document ->
assertEquals(1, document.getPageCount())
val page = document.openPage(0)!!
page.use {
assertEquals(720, it.getPageWidthPoint())
assertEquals(405, it.getPageHeightPoint())
it.openTextPage().use { textPage ->
val count = textPage.textPageCountChars()
assertTrue(count > 0)
assertTrue(textPage.textPageGetText(0, count).orEmpty().contains("Hello PPTX"))
assertTrue(textPage.textPageGetRectsForRanges(intArrayOf(0, 5)).orEmpty().isNotEmpty())
}
}
}
}
@Test
fun `document factory routes pptx to native pptx wrapper`() = runTest {
val file = createTinyPptx()
val cacheDir = File.createTempFile("reader-pptx-cache", "").apply {
delete()
mkdirs()
deleteOnExit()
}
val contentResolver = mockk<ContentResolver>()
val context = mockk<Context>()
every { context.cacheDir } returns cacheDir
every { context.contentResolver } returns contentResolver
every { contentResolver.openInputStream(any<Uri>()) } answers { file.inputStream() }
val document = DocumentFactory.loadDocument(
context = context,
uri = Uri.fromFile(file),
type = FileType.PPTX,
password = null,
pdfiumCore = mockk<PdfiumCoreKt>(relaxed = true)
)
document.use {
assertTrue(it is PptxDocumentWrapper)
}
}
private fun createTinyPptx(): File {
val file = File.createTempFile("reader-test", ".pptx").apply { deleteOnExit() }
ZipOutputStream(file.outputStream()).use { zip ->
zip.putText(
"ppt/presentation.xml",
"""
<p:presentation xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:sldSz cx="9144000" cy="5143500"/>
<p:sldIdLst><p:sldId id="256" r:id="rId1"/></p:sldIdLst>
</p:presentation>
""".trimIndent()
)
zip.putText(
"ppt/_rels/presentation.xml.rels",
"""
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slide" Target="slides/slide1.xml"/>
</Relationships>
""".trimIndent()
)
zip.putText(
"ppt/slides/slide1.xml",
"""
<p:sld xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<p:cSld>
<p:spTree>
<p:sp>
<p:nvSpPr><p:cNvPr id="2" name="Title"/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="500000" y="500000"/><a:ext cx="3000000" cy="800000"/></a:xfrm><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></p:spPr>
<p:txBody><a:bodyPr/><a:p><a:r><a:rPr sz="2800"/><a:t>Hello PPTX</a:t></a:r></a:p></p:txBody>
</p:sp>
<p:pic>
<p:nvPicPr><p:cNvPr id="3" name="Image"/></p:nvPicPr>
<p:blipFill><a:blip r:embed="rId2"><a:alphaModFix amt="35000"/></a:blip><a:srcRect l="10000"/></p:blipFill>
<p:spPr><a:xfrm><a:off x="4000000" y="500000"/><a:ext cx="1000000" cy="1000000"/></a:xfrm></p:spPr>
</p:pic>
<p:sp>
<p:nvSpPr><p:cNvPr id="4" name="Body"/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>
<p:txBody><a:bodyPr><a:normAutofit fontScale="80000" lnSpcReduction="10000"/></a:bodyPr><a:p><a:r><a:t>Inherited Placeholder</a:t></a:r></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="5" name="Centered"/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="4500000" y="1800000"/><a:ext cx="3500000" cy="900000"/></a:xfrm><a:gradFill><a:gsLst><a:gs pos="0"><a:srgbClr val="FFFFFF"/></a:gs><a:gs pos="100000"><a:srgbClr val="DDEEFF"/></a:gs></a:gsLst><a:lin ang="5400000"/></a:gradFill></p:spPr>
<p:txBody><a:bodyPr anchor="ctr" lIns="182880" rIns="182880"/><a:p><a:pPr algn="ctr"/><a:r><a:rPr sz="3600" b="1"/><a:t>Centered</a:t></a:r><a:r><a:rPr sz="1800"/><a:t> Small</a:t></a:r></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="10" name="Numbered"/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="500000" y="1500000"/><a:ext cx="3000000" cy="900000"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:p><a:pPr><a:buAutoNum type="arabicPeriod"/></a:pPr><a:r><a:t>First item</a:t></a:r></a:p><a:p><a:pPr><a:buAutoNum type="arabicPeriod"/></a:pPr><a:r><a:t>Second item</a:t></a:r></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="11" name="Wingding Bullet"/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="500000" y="2400000"/><a:ext cx="3000000" cy="500000"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:p><a:pPr><a:buFont typeface="Wingdings"/><a:buChar char="&#167;"/></a:pPr><a:r><a:t>Wingding bullet</a:t></a:r></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="12" name="Freeform"/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="3500000" y="1550000"/><a:ext cx="600000" cy="600000"/></a:xfrm><a:custGeom><a:pathLst><a:path w="1000" h="1000"><a:moveTo><a:pt x="500" y="0"/></a:moveTo><a:lnTo><a:pt x="1000" y="1000"/></a:lnTo><a:lnTo><a:pt x="0" y="1000"/></a:lnTo><a:close/></a:path></a:pathLst></a:custGeom><a:solidFill><a:srgbClr val="FF0000"/></a:solidFill></p:spPr>
<p:txBody><a:bodyPr/><a:p><a:endParaRPr/></a:p></p:txBody>
</p:sp>
<p:graphicFrame>
<p:nvGraphicFramePr><p:cNvPr id="6" name="Table"/></p:nvGraphicFramePr>
<p:xfrm><a:off x="4500000" y="3000000"/><a:ext cx="3000000" cy="800000"/></p:xfrm>
<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/table">
<a:tbl><a:tblPr firstRow="1" bandRow="1"><a:tableStyleId>{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}</a:tableStyleId></a:tblPr><a:tblGrid><a:gridCol w="1500000"/><a:gridCol w="1500000"/></a:tblGrid>
<a:tr h="800000">
<a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:t>Cell A</a:t></a:r></a:p></a:txBody><a:tcPr marL="12700" anchor="ctr"><a:solidFill><a:srgbClr val="FFF2CC"/></a:solidFill></a:tcPr></a:tc>
<a:tc><a:txBody><a:bodyPr/><a:p><a:r><a:t>Cell B</a:t></a:r></a:p></a:txBody><a:tcPr/></a:tc>
</a:tr>
</a:tbl>
</a:graphicData></a:graphic>
</p:graphicFrame>
<p:grpSp>
<p:nvGrpSpPr><p:cNvPr id="7" name="Group"/></p:nvGrpSpPr>
<p:grpSpPr><a:xfrm><a:off x="500000" y="3000000"/><a:ext cx="2000000" cy="900000"/><a:chOff x="0" y="0"/><a:chExt cx="2000000" cy="900000"/></a:xfrm></p:grpSpPr>
<p:sp>
<p:nvSpPr><p:cNvPr id="8" name="Grouped"/></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="1600000" cy="500000"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr><a:spAutoFit/></a:bodyPr><a:p><a:r><a:t>Grouped Text</a:t></a:r></a:p></p:txBody>
</p:sp>
</p:grpSp>
</p:spTree>
</p:cSld>
</p:sld>
""".trimIndent()
)
zip.putText(
"ppt/slides/_rels/slide1.xml.rels",
"""
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideLayout" Target="../slideLayouts/slideLayout1.xml"/>
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="../media/image1.png"/>
</Relationships>
""".trimIndent()
)
zip.putText(
"ppt/slideLayouts/slideLayout1.xml",
layoutPart()
)
zip.putText(
"ppt/slideLayouts/_rels/slideLayout1.xml.rels",
"""
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/slideMaster" Target="../slideMasters/slideMaster1.xml"/>
</Relationships>
""".trimIndent()
)
zip.putText(
"ppt/slideMasters/slideMaster1.xml",
textPart("Master Text")
)
zip.putText(
"ppt/slideMasters/_rels/slideMaster1.xml.rels",
"""
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/theme" Target="../theme/theme1.xml"/>
</Relationships>
""".trimIndent()
)
zip.putText(
"ppt/theme/theme1.xml",
"""
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:themeElements><a:clrScheme name="Reader">
<a:dk1><a:srgbClr val="000000"/></a:dk1>
<a:lt1><a:srgbClr val="FFFFFF"/></a:lt1>
<a:accent1><a:srgbClr val="3366CC"/></a:accent1>
</a:clrScheme></a:themeElements>
</a:theme>
""".trimIndent()
)
zip.putText(
"ppt/tableStyles.xml",
"""
<a:tblStyleLst xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<a:tblStyle styleId="{5C22544A-7EE6-4342-B048-85BDC9FD1C3A}">
<a:wholeTbl><a:tcTxStyle><a:schemeClr val="dk1"/></a:tcTxStyle><a:tcStyle><a:tcBdr><a:left><a:ln><a:solidFill><a:schemeClr val="lt1"/></a:solidFill></a:ln></a:left></a:tcBdr><a:fill><a:solidFill><a:schemeClr val="accent1"><a:tint val="40000"/></a:schemeClr></a:solidFill></a:fill></a:tcStyle></a:wholeTbl>
<a:firstRow><a:tcTxStyle b="on"><a:schemeClr val="lt1"/></a:tcTxStyle><a:tcStyle><a:fill><a:solidFill><a:schemeClr val="accent1"/></a:solidFill></a:fill></a:tcStyle></a:firstRow>
</a:tblStyle>
</a:tblStyleLst>
""".trimIndent()
)
zip.putBytes("ppt/media/image1.png", byteArrayOf(1, 2, 3, 4))
}
return file
}
private fun textPart(text: String): String {
return """
<p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<p:cSld><p:spTree><p:sp>
<p:spPr><a:xfrm><a:off x="100000" y="4200000"/><a:ext cx="3000000" cy="500000"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:p><a:r><a:t>$text</a:t></a:r></a:p></p:txBody>
</p:sp></p:spTree></p:cSld>
</p:sldLayout>
""".trimIndent()
}
private fun layoutPart(): String {
return """
<p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main">
<p:cSld><p:spTree>
<p:sp>
<p:spPr><a:xfrm><a:off x="100000" y="4200000"/><a:ext cx="3000000" cy="500000"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:p><a:r><a:t>Layout Text</a:t></a:r></a:p></p:txBody>
</p:sp>
<p:sp>
<p:nvSpPr><p:cNvPr id="9" name="Body Placeholder"/><p:nvPr><p:ph type="body" idx="1"/></p:nvPr></p:nvSpPr>
<p:spPr><a:xfrm><a:off x="1000000" y="1800000"/><a:ext cx="4000000" cy="900000"/></a:xfrm></p:spPr>
<p:txBody><a:bodyPr/><a:lstStyle><a:lvl1pPr algn="ctr"><a:defRPr sz="2400"/></a:lvl1pPr></a:lstStyle><a:p><a:r><a:t>Layout Placeholder Prompt</a:t></a:r></a:p></p:txBody>
</p:sp>
</p:spTree></p:cSld>
</p:sldLayout>
""".trimIndent()
}
private fun ZipOutputStream.putText(path: String, text: String) {
putNextEntry(ZipEntry(path))
write(text.toByteArray())
closeEntry()
}
private fun ZipOutputStream.putBytes(path: String, bytes: ByteArray) {
putNextEntry(ZipEntry(path))
write(bytes)
closeEntry()
}
}