Update v1.0.49 (#330)
* Added PDF top tab strip visibility toggle and fixed WebView hit test NPE * Refactored desktop reader screens and state management into specialized components * Added image gallery to reader sidebar and refactored desktop PDF UI components * Implemented EPUB image gallery * Refactored reader and library models to use shared common types, centralizing file type resolution and texture management while removing redundant mapping logic * Standardized UI styling and refactored app navigation layout * Implemented auto-hiding reader chrome and activity tracking in desktop * Refactored reader panels into distinct left and right modal layers with platform-specific sizing and keyboard navigation support. * Added PPTX support for desktop and refactored parsing into a shared module * Implement paid AI features and account management for the desktop application. * Implement AI Hub and enhanced Cloud TTS integration for Desktop * Implement streaming support for AI definition and summarization features * Implement support for password-protected PDFs and file actions in the desktop reader. * Implement cloud synchronization for desktop using Firestore and Google Drive * Implement PDF reflow and "Text View" for the desktop reader * Refactor OPDS logic to use SharedOpdsController * Optimize PDF tile rendering performance * Implement two-page spread support for PDF pagination * Implement two-page spread support for the PDF viewer * Improved shared spread zoom in PDF viewer * Improve PDF spread navigation with fling support and configurable page gaps * Add brightness control to PDF and EPUB readers * Refactor folder synchronization to use shared logic engine * Implement safe string formatting and validation for localized resources * Implement TTS chunk skip navigation * Implement deep-linking and playback controls for TTS media sessions * Implement start index for TTS playback * Improve TTS navigation, prefetching, and notification duration reporting * Implement TTS mini playback bar for background reading * Implement multi-window reader support for the desktop application * Improve desktop modal window management and visibility syncing * Implement localized string support for Desktop and shared UI * Implement language selection and persistence for Desktop * Implement plural string support for Desktop and migrate hardcoded counts to plurals.xml * Implement localized banner messages and UI strings using resource-backed SharedText * Implement compact badge styling for small book covers * Refactor PDF native interaction and improve HTML import memory safety * fix language persistence * Refactor reader overflow menus to use section-based logic * Refactor PDF layout remapping and improve text box interaction * Improve CFI resolution and TTS resume accuracy using dynamic chunk offsets * Centralize PDF annotation export mapping and improve metadata handling * Add support for threaded comments in PDF highlight annotations * Flatten highlight comments into a single thread for PDF export and allow author editing * Integrate page slider into reader chrome and persist toggle state * Handle fragments and queries in EPUB chapter paths * Implement dynamic, theme-aware coloring for the reader slider * Implement customizable app-wide font preference * Implement one-hand zoom gestures in the PDF viewer * Implement File Information dialog for PDF and EPUB readers * Bump version to 1.0.49 (53) * Refactor PDF reader logic into modular components * Add ProGuard rules to prevent R8 optimization issues in EPUB reader screens * Add option to use PDF filenames as display names * Fix preservation of PDF filename display preference in library projection
This commit is contained in:
parent
dc5196526f
commit
9510293ac3
245 changed files with 37538 additions and 12460 deletions
|
|
@ -1,5 +1,6 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.ReaderAiByokSettings
|
||||
import com.aryan.reader.shared.SharedFeaturePolicy
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
|
|
@ -18,6 +19,8 @@ class DesktopBuildProfileTest {
|
|||
assertEquals("Standard edition", profile.buildLabel)
|
||||
assertEquals(SharedFeaturePolicy.Standard, profile.featurePolicy)
|
||||
assertTrue(profile.featurePolicy.networkAccess)
|
||||
assertFalse(profile.featurePolicy.byokAi)
|
||||
assertFalse(profile.byokAiAvailable)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
@ -30,6 +33,8 @@ class DesktopBuildProfileTest {
|
|||
assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy)
|
||||
assertFalse(profile.featurePolicy.networkAccess)
|
||||
assertFalse(profile.featurePolicy.aiAndCloud)
|
||||
assertTrue(profile.featurePolicy.byokAi)
|
||||
assertFalse(profile.byokAiAvailable)
|
||||
assertFalse(profile.featurePolicy.opdsCatalogs)
|
||||
assertFalse(profile.featurePolicy.googleFontsDownload)
|
||||
}
|
||||
|
|
@ -43,6 +48,31 @@ class DesktopBuildProfileTest {
|
|||
assertEquals(SharedFeaturePolicy.OssOffline, profile.featurePolicy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop BYOK settings are only exposed by an online OSS-style policy`() {
|
||||
val settings = ReaderAiByokSettings(
|
||||
geminiKey = "gemini_secret",
|
||||
modelForAll = "gemini:gemini-flash-lite-latest"
|
||||
)
|
||||
val onlineOssPolicy = SharedFeaturePolicy(
|
||||
networkAccess = true,
|
||||
aiAndCloud = true,
|
||||
byokAi = true
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
DesktopBuildProfile(
|
||||
flavor = "oss-online",
|
||||
appName = "Episteme oss",
|
||||
buildLabel = "OSS edition",
|
||||
featurePolicy = onlineOssPolicy
|
||||
).byokAiAvailable
|
||||
)
|
||||
assertEquals(settings, settings.withDesktopFeaturePolicy(onlineOssPolicy))
|
||||
assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.Standard).hideReaderAiFeatures)
|
||||
assertTrue(settings.withDesktopFeaturePolicy(SharedFeaturePolicy.OssOffline).hideReaderAiFeatures)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop diagnostics are disabled unless explicitly enabled`() {
|
||||
assertFalse(desktopDiagnosticsFlag(null))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.HighlightColor
|
||||
import com.aryan.reader.shared.ReaderLocator
|
||||
import com.aryan.reader.shared.UserHighlight
|
||||
import com.aryan.reader.shared.reader.ReaderBookmark
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopCloudSyncMappingTest {
|
||||
@Test
|
||||
fun `book metadata encodes desktop reader state for cloud sync`() {
|
||||
val bookmarkLocator = ReaderLocator(
|
||||
chapterIndex = 2,
|
||||
pageIndex = 4,
|
||||
startOffset = 30,
|
||||
endOffset = 44,
|
||||
textQuote = "marked passage"
|
||||
)
|
||||
val highlightLocator = ReaderLocator(
|
||||
chapterIndex = 2,
|
||||
startOffset = 50,
|
||||
endOffset = 64,
|
||||
textQuote = "highlighted text"
|
||||
)
|
||||
val book = BookItem(
|
||||
id = "book-1",
|
||||
path = null,
|
||||
type = FileType.EPUB,
|
||||
displayName = "Book.epub",
|
||||
timestamp = 1_000L,
|
||||
title = "Book",
|
||||
author = "Author",
|
||||
progressPercentage = 42f,
|
||||
lastPageIndex = 4,
|
||||
readerPosition = ReaderLocator(
|
||||
chapterIndex = 2,
|
||||
pageIndex = 4,
|
||||
startOffset = 10,
|
||||
endOffset = 20
|
||||
),
|
||||
readerBookmarks = listOf(
|
||||
ReaderBookmark(
|
||||
id = "bookmark-1",
|
||||
pageIndex = 4,
|
||||
chapterTitle = "Chapter",
|
||||
preview = "marked passage",
|
||||
locator = bookmarkLocator
|
||||
)
|
||||
),
|
||||
readerHighlights = listOf(
|
||||
UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "desktop:2:50:64",
|
||||
text = "highlighted text",
|
||||
color = HighlightColor.YELLOW,
|
||||
chapterIndex = 2,
|
||||
locator = highlightLocator
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
val metadata = book.toDesktopCloudBookMetadata(hasAnnotations = false, timestamp = 2_000L)
|
||||
val restored = metadata.toDesktopBookItem()
|
||||
|
||||
assertEquals("desktop:2:10:20", metadata.lastPositionCfi)
|
||||
assertEquals(2, metadata.lastChapterIndex)
|
||||
assertEquals(4, metadata.lastPage)
|
||||
assertEquals(42f, metadata.progressPercentage)
|
||||
assertTrue(assertNotNull(metadata.bookmarksJson).contains("desktop:2:30:44"))
|
||||
assertTrue(assertNotNull(metadata.highlightsJson).contains("highlighted text"))
|
||||
assertEquals(book.id, restored.id)
|
||||
assertEquals(2, restored.readerPosition?.chapterIndex)
|
||||
assertEquals(10, restored.readerPosition?.startOffset)
|
||||
assertEquals(1, restored.readerBookmarks.size)
|
||||
assertEquals(2, restored.readerBookmarks.single().locator.chapterIndex)
|
||||
assertEquals(30, restored.readerBookmarks.single().locator.startOffset)
|
||||
assertEquals(44, restored.readerBookmarks.single().locator.endOffset)
|
||||
assertEquals("desktop:2:30:44", restored.readerBookmarks.single().locator.cfi)
|
||||
assertEquals(1, restored.readerHighlights.size)
|
||||
assertEquals(2, restored.readerHighlights.single().locator.chapterIndex)
|
||||
assertEquals(50, restored.readerHighlights.single().locator.startOffset)
|
||||
assertEquals(64, restored.readerHighlights.single().locator.endOffset)
|
||||
assertEquals("desktop:2:50:64", restored.readerHighlights.single().locator.cfi)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `remote metadata without annotation json preserves existing desktop annotations`() {
|
||||
val existingBookmark = ReaderBookmark(
|
||||
id = "bookmark-1",
|
||||
pageIndex = 1,
|
||||
chapterTitle = "Chapter",
|
||||
preview = "local bookmark"
|
||||
)
|
||||
val existingHighlight = UserHighlight(
|
||||
id = "highlight-1",
|
||||
cfi = "desktop:0:12:18",
|
||||
text = "local highlight",
|
||||
color = HighlightColor.BLUE,
|
||||
chapterIndex = 0
|
||||
)
|
||||
val existing = BookItem(
|
||||
id = "book-1",
|
||||
path = "C:/books/Book.epub",
|
||||
type = FileType.EPUB,
|
||||
displayName = "Book.epub",
|
||||
timestamp = 1_000L,
|
||||
readerBookmarks = listOf(existingBookmark),
|
||||
readerHighlights = listOf(existingHighlight)
|
||||
)
|
||||
val remote = DesktopCloudBookMetadata(
|
||||
bookId = existing.id,
|
||||
displayName = existing.displayName,
|
||||
type = FileType.EPUB.name,
|
||||
lastModifiedTimestamp = 2_000L,
|
||||
bookmarksJson = null,
|
||||
highlightsJson = null
|
||||
)
|
||||
|
||||
val restored = remote.toDesktopBookItem(existing = existing)
|
||||
|
||||
assertEquals(listOf(existingBookmark), restored.readerBookmarks)
|
||||
assertEquals(listOf(existingHighlight), restored.readerHighlights)
|
||||
assertEquals(existing.path, restored.path)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import com.aryan.reader.shared.pdf.PdfAnnotationKind
|
||||
import com.aryan.reader.shared.pdf.PdfInkTool
|
||||
import com.aryan.reader.shared.pdf.PdfPageBounds
|
||||
import com.aryan.reader.shared.pdf.PdfPagePoint
|
||||
import com.aryan.reader.shared.pdf.SharedPdfAnnotation
|
||||
import com.aryan.reader.shared.pdf.SharedPdfRichPageLayout
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopPdfFileActionsTest {
|
||||
|
||||
@Test
|
||||
fun `desktop pdf suggested filename follows android suffix format`() {
|
||||
assertEquals(
|
||||
"My_PDF_annotated_1234.pdf",
|
||||
desktopSuggestedPdfFilename("My PDF.pdf", isAnnotated = true, shortId = "1234")
|
||||
)
|
||||
assertEquals(
|
||||
"My_PDF_1234.pdf",
|
||||
desktopSuggestedPdfFilename("My PDF.pdf", isAnnotated = false, shortId = "1234")
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf export choice waits for sidecars before defaulting to original`() {
|
||||
assertTrue(
|
||||
shouldShowDesktopPdfAnnotationExportChoice(
|
||||
sidecarsReady = false,
|
||||
annotations = emptyList(),
|
||||
richTextPageLayouts = emptyList()
|
||||
)
|
||||
)
|
||||
assertFalse(
|
||||
shouldShowDesktopPdfAnnotationExportChoice(
|
||||
sidecarsReady = true,
|
||||
annotations = emptyList(),
|
||||
richTextPageLayouts = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf export choice appears for exportable annotations`() {
|
||||
val ink = SharedPdfAnnotation(
|
||||
id = "ink",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = PdfInkTool.PEN,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.1f), PdfPagePoint(0.2f, 0.2f)),
|
||||
colorArgb = 0xFF000000.toInt()
|
||||
)
|
||||
val highlight = SharedPdfAnnotation(
|
||||
id = "highlight",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
bounds = PdfPageBounds(0.1f, 0.1f, 0.3f, 0.2f),
|
||||
colorArgb = 0x66FFFF00
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
shouldShowDesktopPdfAnnotationExportChoice(
|
||||
sidecarsReady = true,
|
||||
annotations = listOf(ink, highlight),
|
||||
richTextPageLayouts = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf export choice ignores non-exportable ink by itself`() {
|
||||
val eraser = SharedPdfAnnotation(
|
||||
id = "eraser",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.INK,
|
||||
tool = PdfInkTool.ERASER,
|
||||
points = listOf(PdfPagePoint(0.1f, 0.1f), PdfPagePoint(0.2f, 0.2f)),
|
||||
colorArgb = 0x00000000
|
||||
)
|
||||
|
||||
assertFalse(
|
||||
shouldShowDesktopPdfAnnotationExportChoice(
|
||||
sidecarsReady = true,
|
||||
annotations = listOf(eraser),
|
||||
richTextPageLayouts = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf export choice appears for recomputable highlight ranges`() {
|
||||
val highlight = SharedPdfAnnotation(
|
||||
id = "highlight-range",
|
||||
pageIndex = 0,
|
||||
kind = PdfAnnotationKind.HIGHLIGHT,
|
||||
colorArgb = 0x66FFFF00,
|
||||
rangeStartIndex = 4,
|
||||
rangeEndIndex = 12
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
shouldShowDesktopPdfAnnotationExportChoice(
|
||||
sidecarsReady = true,
|
||||
annotations = listOf(highlight),
|
||||
richTextPageLayouts = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf export choice appears for rich text layouts`() {
|
||||
val richLayout = SharedPdfRichPageLayout(
|
||||
pageIndex = 0,
|
||||
visibleText = AnnotatedString("Margin note"),
|
||||
globalStartIndex = 0,
|
||||
globalEndIndex = 11,
|
||||
pageHeightPx = 1200f
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
shouldShowDesktopPdfAnnotationExportChoice(
|
||||
sidecarsReady = true,
|
||||
annotations = emptyList(),
|
||||
richTextPageLayouts = listOf(richLayout)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop pdf password errors can be detected through wrappers`() {
|
||||
assertTrue(DesktopPdfPasswordException("locked.pdf").isDesktopPdfPasswordException())
|
||||
assertTrue(RuntimeException(DesktopPdfPasswordException("locked.pdf")).isDesktopPdfPasswordException())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.BookItem
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.SharedLibraryStateProjector
|
||||
import com.aryan.reader.shared.SharedReaderScreenState
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopPdfReflowTest {
|
||||
@Test
|
||||
fun `desktop reflow ids and labels match Android text view convention`() {
|
||||
assertEquals("abc_reflow", desktopPdfReflowBookId("abc"))
|
||||
assertTrue(isDesktopPdfReflowBookId("abc_reflow"))
|
||||
assertEquals("Source (Text View)", desktopPdfReflowDisplayName("Source"))
|
||||
assertEquals("Source (Reflow)", desktopPdfReflowTitle("Source"))
|
||||
assertEquals("Generated", desktopPdfReflowGeneratedAuthor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop reflow filename is safe for path-like book ids`() {
|
||||
val fileName = desktopPdfReflowFileName("C:/Books/My Source.pdf", "My Source")
|
||||
|
||||
assertEquals("C__Books_My_Source.pdf_reflow.html", fileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop reflow book item maps generated html into text reader format`() {
|
||||
val source = BookItem(
|
||||
id = "pdf-id",
|
||||
path = "C:/Books/source.pdf",
|
||||
type = FileType.PDF,
|
||||
displayName = "source.pdf",
|
||||
timestamp = 1L,
|
||||
title = "Source"
|
||||
)
|
||||
val generatedFile = File("build/test-tmp/source_reflow.html")
|
||||
|
||||
val item = desktopPdfReflowBookItem(
|
||||
sourceBook = source,
|
||||
generatedFile = generatedFile,
|
||||
nowMillis = 42L,
|
||||
initialPageIndex = 7
|
||||
)
|
||||
|
||||
assertEquals("pdf-id_reflow", item.id)
|
||||
assertEquals(FileType.HTML, item.type)
|
||||
assertEquals(generatedFile.absolutePath, item.path)
|
||||
assertEquals("Source (Text View)", item.displayName)
|
||||
assertEquals("Source (Reflow)", item.title)
|
||||
assertEquals("Generated", item.author)
|
||||
assertEquals(42L, item.timestamp)
|
||||
assertEquals(7, item.lastPageIndex)
|
||||
assertTrue(item.isRecent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `desktop library projection hides generated reflow books but keeps tabs open`() {
|
||||
val source = BookItem(
|
||||
id = "pdf-id",
|
||||
path = "C:/Books/source.pdf",
|
||||
type = FileType.PDF,
|
||||
displayName = "source.pdf",
|
||||
timestamp = 1L,
|
||||
title = "Source"
|
||||
)
|
||||
val reflow = desktopPdfReflowBookItem(
|
||||
sourceBook = source,
|
||||
generatedFile = File("build/test-tmp/source_reflow.html"),
|
||||
nowMillis = 2L,
|
||||
initialPageIndex = 3
|
||||
)
|
||||
val projected = SharedLibraryStateProjector().projectDesktopLibraryState(
|
||||
state = SharedReaderScreenState(
|
||||
rawLibraryBooks = listOf(source, reflow),
|
||||
openTabIds = listOf(reflow.id),
|
||||
activeTabBookId = reflow.id
|
||||
),
|
||||
shelfRecords = emptyList(),
|
||||
shelfRefs = emptyList()
|
||||
)
|
||||
|
||||
assertEquals(listOf(source.id), projected.libraryBooks.map { it.id })
|
||||
assertTrue(projected.rawLibraryBooks.any { it.id == reflow.id })
|
||||
assertTrue(projected.recentBooks.none { it.id == reflow.id })
|
||||
assertEquals(listOf(reflow.id), projected.openTabIds)
|
||||
assertEquals(reflow.id, projected.activeTabBookId)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.pptx.SharedPptxDeckCache
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopPptxDocumentTest {
|
||||
@Test
|
||||
fun `pptx document loads text links and renderable slides for pdf reader surface`() = withTempDir { dir ->
|
||||
val pptx = File(dir, "slides.pptx")
|
||||
writeMinimalPptx(pptx)
|
||||
|
||||
val sharedDeck = SharedPptxDeckCache.load(pptx)
|
||||
assertEquals(1, sharedDeck.slides.size)
|
||||
assertTrue(sharedDeck.slides.single().text.contains("Hello PPTX"))
|
||||
|
||||
val document = DesktopPdfium.loadPptx(pptx)
|
||||
try {
|
||||
assertEquals("PPTX", document.formatLabel)
|
||||
assertEquals(1, document.pageCount)
|
||||
assertEquals(720f, document.pageSizes.single().width)
|
||||
assertEquals(540f, document.pageSizes.single().height)
|
||||
|
||||
val textPage = document.textPageData(0)
|
||||
assertTrue(textPage.text.contains("Hello PPTX"))
|
||||
assertTrue(textPage.chars.isNotEmpty())
|
||||
assertNotNull(DesktopPdfium.charIndexAt(document, pageIndex = 0, normalizedX = 0.13f, normalizedY = 0.16f))
|
||||
assertTrue(DesktopPdfium.textRectsForRange(document, pageIndex = 0, startIndex = 0, endIndex = 4).isNotEmpty())
|
||||
|
||||
val link = DesktopPdfium.linkAt(document, pageIndex = 0, normalizedX = 0.3f, normalizedY = 0.2f)
|
||||
assertEquals("https://example.com/slides", link?.uri)
|
||||
|
||||
val image = DesktopPdfium.renderPageBufferedImage(document, pageIndex = 0, scale = 1f)
|
||||
assertEquals(720, image.width)
|
||||
assertEquals(540, image.height)
|
||||
} finally {
|
||||
document.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun withTempDir(block: (File) -> Unit) {
|
||||
val dir = Files.createTempDirectory("reader-desktop-pptx").toFile()
|
||||
try {
|
||||
block(dir)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeMinimalPptx(file: File) {
|
||||
ZipOutputStream(file.outputStream()).use { zip ->
|
||||
zip.writeEntry(
|
||||
"[Content_Types].xml",
|
||||
"""
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/ppt/presentation.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml"/>
|
||||
<Override PartName="/ppt/slides/slide1.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slide+xml"/>
|
||||
</Types>
|
||||
""".trimIndent()
|
||||
)
|
||||
zip.writeEntry(
|
||||
"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="6858000"/>
|
||||
<p:sldIdLst>
|
||||
<p:sldId id="256" r:id="rId1"/>
|
||||
</p:sldIdLst>
|
||||
</p:presentation>
|
||||
""".trimIndent()
|
||||
)
|
||||
zip.writeEntry(
|
||||
"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.writeEntry(
|
||||
"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:bg>
|
||||
<p:bgPr>
|
||||
<a:solidFill><a:srgbClr val="FFFFFF"/></a:solidFill>
|
||||
</p:bgPr>
|
||||
</p:bg>
|
||||
<p:spTree>
|
||||
<p:nvGrpSpPr><p:cNvPr id="1" name=""/><p:cNvGrpSpPr/><p:nvPr/></p:nvGrpSpPr>
|
||||
<p:grpSpPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="0" cy="0"/><a:chOff x="0" y="0"/><a:chExt cx="0" cy="0"/></a:xfrm></p:grpSpPr>
|
||||
<p:sp>
|
||||
<p:nvSpPr>
|
||||
<p:cNvPr id="2" name="Title"/>
|
||||
<p:cNvSpPr/>
|
||||
<p:nvPr><a:hlinkClick r:id="rId2"/></p:nvPr>
|
||||
</p:nvSpPr>
|
||||
<p:spPr>
|
||||
<a:xfrm><a:off x="914400" y="914400"/><a:ext cx="3657600" cy="914400"/></a:xfrm>
|
||||
<a:prstGeom prst="rect"><a:avLst/></a:prstGeom>
|
||||
<a:solidFill><a:srgbClr val="EAF2FF"/></a:solidFill>
|
||||
<a:ln><a:solidFill><a:srgbClr val="4472C4"/></a:solidFill></a:ln>
|
||||
</p:spPr>
|
||||
<p:txBody>
|
||||
<a:bodyPr/>
|
||||
<a:lstStyle/>
|
||||
<a:p>
|
||||
<a:pPr algn="l"/>
|
||||
<a:r>
|
||||
<a:rPr sz="2400"><a:solidFill><a:srgbClr val="1F1F1F"/></a:solidFill></a:rPr>
|
||||
<a:t>Hello PPTX</a:t>
|
||||
</a:r>
|
||||
</a:p>
|
||||
</p:txBody>
|
||||
</p:sp>
|
||||
</p:spTree>
|
||||
</p:cSld>
|
||||
</p:sld>
|
||||
""".trimIndent()
|
||||
)
|
||||
zip.writeEntry(
|
||||
"ppt/slides/_rels/slide1.xml.rels",
|
||||
"""
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId2"
|
||||
Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
|
||||
Target="https://example.com/slides"
|
||||
TargetMode="External"/>
|
||||
</Relationships>
|
||||
""".trimIndent()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ZipOutputStream.writeEntry(name: String, contents: String) {
|
||||
putNextEntry(ZipEntry(name))
|
||||
write(contents.toByteArray(Charsets.UTF_8))
|
||||
closeEntry()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import com.aryan.reader.shared.FileType
|
||||
import com.aryan.reader.shared.ui.SharedAppTab
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DesktopReaderWindowStateTest {
|
||||
|
||||
@Test
|
||||
fun `opening a new reader creates a window`() {
|
||||
val opening = readerOpening("book-1", requestId = 1)
|
||||
|
||||
val decision = emptyList<DesktopReaderWindowState>().openOrFocusDesktopReaderWindow(
|
||||
opening = opening,
|
||||
force = false
|
||||
)
|
||||
|
||||
assertTrue(decision.shouldStartOpen)
|
||||
assertEquals(listOf("book-1"), decision.windows.map { it.bookId })
|
||||
assertEquals(1L, decision.windows.single().focusRequestId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opening an already open reader focuses the existing window`() {
|
||||
val opening = readerOpening("book-1", requestId = 1)
|
||||
val first = emptyList<DesktopReaderWindowState>()
|
||||
.openOrFocusDesktopReaderWindow(opening, force = false)
|
||||
.windows
|
||||
|
||||
val decision = first.openOrFocusDesktopReaderWindow(
|
||||
opening = readerOpening("book-1", requestId = 2),
|
||||
force = false
|
||||
)
|
||||
|
||||
assertFalse(decision.shouldStartOpen)
|
||||
assertEquals(1, decision.windows.size)
|
||||
assertEquals(2L, decision.windows.single().focusRequestId)
|
||||
assertEquals(1L, decision.windows.single().opening.requestId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `forcing an already open reader replaces the opening request`() {
|
||||
val opening = readerOpening("book-1", requestId = 1)
|
||||
val first = emptyList<DesktopReaderWindowState>()
|
||||
.openOrFocusDesktopReaderWindow(opening, force = false)
|
||||
.windows
|
||||
|
||||
val decision = first.openOrFocusDesktopReaderWindow(
|
||||
opening = readerOpening("book-1", requestId = 2),
|
||||
force = true
|
||||
)
|
||||
|
||||
assertTrue(decision.shouldStartOpen)
|
||||
assertEquals(1, decision.windows.size)
|
||||
assertEquals(2L, decision.windows.single().opening.requestId)
|
||||
assertEquals(2L, decision.windows.single().focusRequestId)
|
||||
}
|
||||
|
||||
private fun readerOpening(bookId: String, requestId: Long): DesktopReaderOpening {
|
||||
return DesktopReaderOpening(
|
||||
requestId = requestId,
|
||||
bookId = bookId,
|
||||
title = "Book $bookId",
|
||||
formatLabel = FileType.EPUB.name,
|
||||
returnTab = SharedAppTab.LIBRARY
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.nio.file.Files
|
||||
import java.util.Locale
|
||||
|
||||
class DesktopStringResourcesTest {
|
||||
@Test
|
||||
fun buildsAndroidResourcePathsForRegionalLocale() {
|
||||
val paths = desktopAndroidStringResourcePaths(Locale("pt", "BR"))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"desktop-android-res/values-pt-rBR/strings.xml",
|
||||
"desktop-android-res/values-pt/strings.xml"
|
||||
),
|
||||
paths
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildsAndroidPluralResourcePathsForRegionalLocale() {
|
||||
val paths = desktopAndroidPluralResourcePaths(Locale("pt", "BR"))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"desktop-android-res/values-pt-rBR/plurals.xml",
|
||||
"desktop-android-res/values-pt/plurals.xml"
|
||||
),
|
||||
paths
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesAndroidStringXmlAndDecodesEscapes() {
|
||||
val xml = """
|
||||
<resources>
|
||||
<string name="line">One\nTwo</string>
|
||||
<string name="quote">Don\'t stop</string>
|
||||
</resources>
|
||||
""".trimIndent()
|
||||
|
||||
val parsed = parseAndroidStringXml(ByteArrayInputStream(xml.toByteArray()))
|
||||
|
||||
assertEquals("One\nTwo", parsed["line"])
|
||||
assertEquals("Don't stop", parsed["quote"])
|
||||
assertTrue(parsed.containsKey("line"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsesAndroidPluralXmlAndDecodesEscapes() {
|
||||
val xml = """
|
||||
<resources>
|
||||
<plurals name="book_count">
|
||||
<item quantity="one">%1${'$'}d book</item>
|
||||
<item quantity="other">%1${'$'}d books</item>
|
||||
</plurals>
|
||||
<plurals name="quoted_count">
|
||||
<item quantity="one">Don\'t skip %1${'$'}d file</item>
|
||||
<item quantity="other">Don\'t skip %1${'$'}d files</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
""".trimIndent()
|
||||
|
||||
val parsed = parseAndroidPluralXml(ByteArrayInputStream(xml.toByteArray()))
|
||||
|
||||
assertEquals("%1${'$'}d book", parsed["book_count"]?.get("one"))
|
||||
assertEquals("%1${'$'}d books", parsed["book_count"]?.get("other"))
|
||||
assertEquals("Don't skip %1${'$'}d file", parsed["quoted_count"]?.get("one"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun choosesDesktopPluralQuantityForSupportedLanguages() {
|
||||
val slavicQuantities = setOf("one", "few", "many", "other")
|
||||
val arabicQuantities = setOf("zero", "one", "two", "few", "many", "other")
|
||||
|
||||
assertEquals("one", desktopAndroidPluralQuantity(Locale("ru"), 21, slavicQuantities))
|
||||
assertEquals("few", desktopAndroidPluralQuantity(Locale("ru"), 22, slavicQuantities))
|
||||
assertEquals("many", desktopAndroidPluralQuantity(Locale("ru"), 25, slavicQuantities))
|
||||
assertEquals("few", desktopAndroidPluralQuantity(Locale("pl"), 2, slavicQuantities))
|
||||
assertEquals("one", desktopAndroidPluralQuantity(Locale("fr"), 0, setOf("one", "other")))
|
||||
assertEquals("zero", desktopAndroidPluralQuantity(Locale("ar"), 0, arabicQuantities))
|
||||
assertEquals("other", desktopAndroidPluralQuantity(Locale("ja"), 1, setOf("other")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fallsBackToOtherPluralQuantityWhenPreferredIsUnavailable() {
|
||||
val selected = desktopAndroidPluralQuantity(Locale("ru"), 2, setOf("one", "other"))
|
||||
|
||||
assertEquals("other", selected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun normalizesDesktopLanguageTagsForAndroidResources() {
|
||||
assertEquals(null, normalizeDesktopLanguageTag(null))
|
||||
assertEquals("id", normalizeDesktopLanguageTag("in"))
|
||||
assertEquals("pt-BR", normalizeDesktopLanguageTag("pt_br"))
|
||||
assertEquals("zh-CN", normalizeDesktopLanguageTag("zh-cn"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resolvesSelectedDesktopLanguageOptionByNormalizedTag() {
|
||||
val option = selectedDesktopLanguageOption("pt_br")
|
||||
|
||||
assertEquals("pt-BR", option.normalizedTag)
|
||||
assertEquals("language_portuguese_brazilian", option.labelKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun desktopLanguageSettingsStorePersistsLanguageAcrossInstances() {
|
||||
val tempDirectory = Files.createTempDirectory("episteme-desktop-language-test")
|
||||
val settingsFile = tempDirectory.resolve("language.properties").toFile()
|
||||
|
||||
try {
|
||||
DesktopLanguageSettingsStore(settingsFile).save(DesktopLanguageSettings("pt_br"))
|
||||
|
||||
assertEquals(
|
||||
"pt-BR",
|
||||
DesktopLanguageSettingsStore(settingsFile).load().languageTag
|
||||
)
|
||||
|
||||
DesktopLanguageSettingsStore(settingsFile).save(DesktopLanguageSettings(null))
|
||||
|
||||
assertEquals(
|
||||
null,
|
||||
DesktopLanguageSettingsStore(settingsFile).load().languageTag
|
||||
)
|
||||
} finally {
|
||||
settingsFile.delete()
|
||||
tempDirectory.toFile().delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
package com.aryan.reader.desktop
|
||||
|
||||
import java.nio.file.Files
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class DesktopSummaryCacheStoreTest {
|
||||
@Test
|
||||
fun `stores lists and deletes cached summaries`() {
|
||||
val store = DesktopSummaryCacheStore(Files.createTempDirectory("reader-summary-cache").toFile())
|
||||
|
||||
store.saveSummary("book-a", 2, "Chapter 3", "A cached summary.")
|
||||
store.saveSummary("book-a", 0, "Chapter 1", "The first cached summary.")
|
||||
|
||||
assertEquals("A cached summary.", store.getSummary("book-a", 2))
|
||||
assertEquals(
|
||||
listOf(
|
||||
DesktopCachedSummaryItem(0, "Chapter 1", "The first cached summary."),
|
||||
DesktopCachedSummaryItem(2, "Chapter 3", "A cached summary.")
|
||||
),
|
||||
store.getAllSummaries("book-a")
|
||||
)
|
||||
|
||||
store.deleteSummary("book-a", 2)
|
||||
|
||||
assertNull(store.getSummary("book-a", 2))
|
||||
assertEquals(1, store.getAllSummaries("book-a").size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps books isolated and ignores blank summaries`() {
|
||||
val store = DesktopSummaryCacheStore(Files.createTempDirectory("reader-summary-cache-isolated").toFile())
|
||||
|
||||
store.saveSummary("book-a", 0, "Chapter 1", "A summary.")
|
||||
store.saveSummary("book-b", 0, "Chapter 1", "Another summary.")
|
||||
store.saveSummary("book-a", 1, "Chapter 2", " ")
|
||||
|
||||
assertEquals("A summary.", store.getSummary("book-a", 0))
|
||||
assertEquals("Another summary.", store.getSummary("book-b", 0))
|
||||
assertNull(store.getSummary("book-a", 1))
|
||||
|
||||
store.clearBookCache("book-a")
|
||||
|
||||
assertNull(store.getSummary("book-a", 0))
|
||||
assertEquals("Another summary.", store.getSummary("book-b", 0))
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue