Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ md-backup/
.claude

# Local/generated docs
/docs/future-improvements-exploration.md
/docs/main-activity-modularisation-checklist.md
/docs/reader-image-page-navigation-checklist.md
/docs/performance-hindrances.md
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package com.kairo.reader.data.books

import android.net.Uri
import android.util.Log
import androidx.room.Room
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.kairo.reader.core.dispatchers.DefaultDispatcherProvider
import com.kairo.reader.data.local.BookEntity
import com.kairo.reader.data.local.ChapterEntity
import com.kairo.reader.data.local.KairoDatabase
import com.kairo.reader.data.search.LibrarySearchRepositoryImpl
import java.io.File
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class LargeChapterDeviceTest {
@Test
fun importedLargeTextCanBeReadBackWithoutLosingContent() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val database = Room.inMemoryDatabaseBuilder(context, KairoDatabase::class.java).build()
val source = File.createTempFile("large-chapter-regression-", ".txt", context.cacheDir)
try {
val dispatchers = DefaultDispatcherProvider()
val repository = BookRepositoryImpl(
database.bookDao(),
database.epubNavigationDao(),
listOf(TextFileBookParser(dispatchers)),
WebArticleExtractor(dispatchers),
context,
dispatchers,
)
source.writeText("A reader should be able to open the entire imported book.\n\n".repeat(25_000))
val imported = repository.importBook(Uri.fromFile(source)).book
val expected = imported.chapters.single()
Log.i(
"ReaderRegression",
"Imported ${source.length()} bytes; chapter row text bytes=" +
(expected.htmlContent.toByteArray().size + expected.plainText.toByteArray().size)
)
val restored = repository.getChapter(imported.id, expected.index)
assertEquals(expected.plainText, restored.plainText)
assertEquals(expected.htmlContent, restored.htmlContent)
} finally {
database.close()
source.delete()
}
}

@Test
fun existingLargeUnicodeChapterCanBeReadSearchedAndComparedWithoutChangingCoordinates() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val database = Room.inMemoryDatabaseBuilder(context, KairoDatabase::class.java).build()
try {
val prefix = "𐐀é漢🙂 ".repeat(160_000) + "\u0000"
val plainText = prefix + "needle at the end"
val chapter = ChapterEntity("large", 7, "Large chapter", "<nav><p>$plainText</p></nav>", plainText)
database.bookDao().insertBook(
BookEntity("large", "Large legacy book", emptyList(), "en", null),
listOf(chapter),
emptyList(),
)
assertEquals(chapter, database.bookDao().getChapter("large", 7))
assertEquals(listOf(chapter), database.bookDao().getChaptersWithContent("large"))
assertEquals(plainText, database.epubNavigationDao().getChapterCoordinates("large").single().plainText)
assertEquals(chapter.htmlContent, database.epubNavigationDao().getChapterHtmlContent("large", 7))
assertEquals(
chapter.htmlContent,
database.epubNavigationDao().getMarkerlessNavigationCandidates("large", "absent-marker", 5_000_000, 1)
.single().htmlContent,
)
val search = LibrarySearchRepositoryImpl(
database.searchDao(),
database.savedAnnotationDao(),
DefaultDispatcherProvider(),
).search("needle at the end", "large").single()
assertEquals(7, search.chapterIndex)
assertEquals(prefix.codePointCount(0, prefix.length), search.matchStartCodePointOffset)
assertNull(database.bookDao().getChapter("large", 8))
} finally {
database.close()
}
}

@Test
fun emptyAndExactChunkBoundaryBodiesRoundTrip() = runBlocking {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val database = Room.inMemoryDatabaseBuilder(context, KairoDatabase::class.java).build()
try {
val chapters = listOf(
ChapterEntity("boundary", 0, null, "", ""),
ChapterEntity("boundary", 1, null, "a".repeat(128 * 1024), "é".repeat(64 * 1024)),
)
database.bookDao().insertBook(
BookEntity("boundary", "Boundary book", emptyList(), "en", null),
chapters,
emptyList(),
)
assertEquals(chapters, database.bookDao().getChaptersWithContent("boundary"))
} finally {
database.close()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
package com.kairo.reader.ui.navigation

import android.net.Uri
import android.util.Log
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kairo.reader.KairoApplication
import com.kairo.reader.R
import com.kairo.reader.TestActivity
import com.kairo.reader.core.model.BookId
import com.kairo.reader.core.model.UserPreferences
import com.kairo.reader.ui.reader.ReaderViewModel
import com.kairo.reader.ui.theme.KairoTheme
import java.io.File
import java.util.UUID
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/** Exercises the real reader route, using only a uniquely named disposable book. */
@RunWith(AndroidJUnit4::class)
class ReaderPositionDeviceTest {
@get:Rule
val compose = createAndroidComposeRule<TestActivity>()

@Test
fun leavingAfterAChapterChangeRestoresTheVisiblePosition() = runBlocking {
val container = compose.activity.application as KairoApplication
val source = createBook(container.cacheDir)
var bookId: BookId? = null
val visible = mutableStateOf(true)
try {
val book = container.libraryRepository.import(Uri.fromFile(source)).book
bookId = book.id
assertEquals(2, book.chapters.size)
lateinit var nav: NavHostController
compose.setContent {
KairoTheme {
if (visible.value) {
nav = rememberNavController()
NavHost(navController = nav, startDestination = KairoRoutes.LIBRARY) {
composable(KairoRoutes.LIBRARY) {
Button(onClick = { nav.navigate(KairoRoutes.reader(book.id.value)) }) {
Text("Open regression book")
}
}
readerDestinations(
ReaderDestinationDependencies(
container, nav, UserPreferences(), 250, false, null, {}, {}, {}, {},
),
)
}
}
}
}
compose.onNodeWithText("Open regression book").performClick()
compose.waitUntil(15_000) { nav.currentBackStackEntry?.destination?.route == KairoRoutes.READER }
val viewModel = compose.runOnIdle {
ViewModelProvider(requireNotNull(nav.currentBackStackEntry))[ReaderViewModel::class.java]
}
compose.waitUntil(15_000) { viewModel.uiState.value.chapterData != null }
val next = compose.activity.getString(R.string.content_desc_next_page)
var pageTurns = 0
while (viewModel.uiState.value.chapterIndex == 0 && pageTurns < 30) {
compose.onNodeWithContentDescription(next).performClick()
compose.waitForIdle()
pageTurns++
}
compose.waitUntil(15_000) {
viewModel.uiState.value.chapterIndex == 1 && viewModel.uiState.value.chapterData != null
}
assertTrue(pageTurns > 1)
val expected = viewModel.uiState.value.focusIndex
compose.onNodeWithContentDescription(compose.activity.getString(R.string.content_desc_go_to_library))
.performClick()
compose.waitUntil(15_000) { nav.currentBackStackEntry?.destination?.route == KairoRoutes.LIBRARY }
val saved = requireNotNull(container.readingPositionRepository.getPosition(book.id))
Log.i("ReaderRegression", "Chapter switch: visible=$expected, saved=${saved.tokenIndex}, chapter=${saved.chapterIndex}")
assertEquals(1, saved.chapterIndex)
assertEquals(expected, saved.tokenIndex)
compose.onNodeWithText("Open regression book").performClick()
compose.waitUntil(15_000) { nav.currentBackStackEntry?.destination?.route == KairoRoutes.READER }
val reopened = compose.runOnIdle {
ViewModelProvider(requireNotNull(nav.currentBackStackEntry))[ReaderViewModel::class.java]
}
compose.waitUntil(15_000) { reopened.uiState.value.chapterData != null }
assertEquals(expected, reopened.uiState.value.focusIndex)
} finally {
compose.runOnIdle { visible.value = false }
compose.waitForIdle()
bookId?.let { id ->
container.readingSessionCoordinator.finalizeReader(id)
container.readingSessionCoordinator.awaitIdle()
container.libraryRepository.delete(id.value)
}
source.delete()
}
}

private fun createBook(directory: File): File {
val source = File.createTempFile("position-regression-", ".epub", directory)
ZipOutputStream(source.outputStream()).use { zip ->
zip.putNextEntry(ZipEntry("META-INF/container.xml"))
zip.write("<container><rootfiles><rootfile full-path=\"content.opf\"/></rootfiles></container>".toByteArray())
zip.closeEntry()
zip.putNextEntry(ZipEntry("content.opf"))
zip.write(
"""<package xmlns="http://www.idpf.org/2007/opf" version="3.0">
<metadata><title>Position regression ${UUID.randomUUID()}</title><language>en</language></metadata>
<manifest><item id="one" href="one.xhtml" media-type="application/xhtml+xml"/>
<item id="two" href="two.xhtml" media-type="application/xhtml+xml"/></manifest>
<spine><itemref idref="one"/><itemref idref="two"/></spine></package>""".toByteArray(),
)
zip.closeEntry()
listOf("one", "two").forEach { chapter ->
zip.putNextEntry(ZipEntry("$chapter.xhtml"))
val paragraphs = (1..60).joinToString("") { index ->
"<p>Paragraph $index in chapter $chapter. Reading should always resume at the visible word.</p>"
}
zip.write("<html><head><title>Chapter $chapter</title></head><body>$paragraphs</body></html>".toByteArray())
zip.closeEntry()
}
}
return source
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package com.kairo.reader.ui.reader

import android.util.Log
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.Text
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.kairo.reader.TestActivity
import com.kairo.reader.ui.theme.KairoTheme
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ReaderFocusNavigationDeviceTest {
@get:Rule
val compose = createAndroidComposeRule<TestActivity>()

@Test
fun navigationWithinTheSamePageScrollsToTheNewFocus() {
val focus = mutableIntStateOf(0)
lateinit var list: LazyListState
compose.setContent {
KairoTheme {
val holder = rememberReaderListState("same-chapter-and-page", focus.intValue, 20, false)
list = holder.listState
LazyColumn(Modifier.height(180.dp), state = list) {
items(20) { index -> Text("Paragraph $index", Modifier.height(60.dp)) }
}
}
}
compose.onNodeWithText("Paragraph 0").assertIsDisplayed()
compose.runOnIdle { focus.intValue = 12 }
compose.waitForIdle()
compose.runOnIdle {
Log.i("ReaderRegression", "Navigation focus=12, first visible paragraph=${list.firstVisibleItemIndex}")
assertEquals(12, list.firstVisibleItemIndex)
}
compose.onNodeWithText("Paragraph 12").assertIsDisplayed()
}

@Test
fun focusingAnAlreadyVisibleParagraphDoesNotJumpTheViewport() {
val focus = mutableIntStateOf(0)
lateinit var list: LazyListState
compose.setContent {
KairoTheme {
list = rememberReaderListState("same-page", focus.intValue, 20, false).listState
LazyColumn(Modifier.height(180.dp), state = list) {
items(20) { index -> Text("Paragraph $index", Modifier.height(60.dp)) }
}
}
}
compose.runOnIdle { runBlocking { list.scrollBy(12f) } }
val before = compose.runOnIdle { list.firstVisibleItemIndex to list.firstVisibleItemScrollOffset }
compose.runOnIdle { focus.intValue = 1 }
compose.waitForIdle()
compose.runOnIdle {
assertEquals(before, list.firstVisibleItemIndex to list.firstVisibleItemScrollOffset)
}
}
}
35 changes: 1 addition & 34 deletions app/src/main/java/com/kairo/reader/data/local/BookDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import com.kairo.reader.core.tokenization.CHAPTER_WORD_COUNT_VERSION
import kotlinx.coroutines.flow.Flow

@Dao
interface BookDao {
interface BookDao : ChapterContentQueries {
@Transaction
suspend fun insertBook(
book: BookEntity,
Expand Down Expand Up @@ -151,26 +151,6 @@ interface BookDao {
@Query("SELECT languageTag FROM books WHERE id = :bookId LIMIT 1")
suspend fun getBookLanguageTag(bookId: String): String?

@Query(
"""
SELECT bookId, `index`, title, '' AS htmlContent, '' AS plainText, imagePaths, wordCount, wordCountVersion
FROM chapters
WHERE bookId = :bookId
ORDER BY `index`
""",
)
suspend fun getChapters(bookId: String): List<ChapterEntity>

@Query(
"""
SELECT bookId, `index`, title, htmlContent, plainText, imagePaths, wordCount, wordCountVersion
FROM chapters
WHERE bookId = :bookId
ORDER BY `index`
""",
)
suspend fun getChaptersWithContent(bookId: String): List<ChapterEntity>

@Query(
"""
SELECT bookId, entryIndex, label, depth, chapterIndex, characterOffset
Expand All @@ -181,19 +161,6 @@ interface BookDao {
)
suspend fun getTableOfContentsEntries(bookId: String): List<TableOfContentsEntryEntity>

@Query(
"""
SELECT bookId, `index`, title, htmlContent, plainText, imagePaths, wordCount, wordCountVersion
FROM chapters
WHERE bookId = :bookId AND `index` = :index
LIMIT 1
""",
)
suspend fun getChapter(
bookId: String,
index: Int,
): ChapterEntity?

@Query(
"""
UPDATE chapters
Expand Down
Loading
Loading