diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 8d29580..de561f2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -9,7 +9,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
- id("org.jetbrains.kotlin.plugin.serialization") version "2.1.20"
+ alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.ksp)
id("com.diffplug.spotless") version "8.2.1"
alias(libs.plugins.kover)
@@ -243,8 +243,6 @@ dependencies {
ksp(libs.androidx.room.compiler)
- implementation("androidx.compose.material:material-icons-extended:1.7.8")
-
implementation("androidx.appcompat:appcompat:1.7.1")
//noinspection GradleDependency (Updating these might cause the custom toolbox in pagination to break)
diff --git a/app/src/androidTest/assets/epub/reader_test_book.epub b/app/src/androidTest/assets/epub/reader_test_book.epub
new file mode 100644
index 0000000..3858f26
Binary files /dev/null and b/app/src/androidTest/assets/epub/reader_test_book.epub differ
diff --git a/app/src/androidTest/fixtures/epub/README.md b/app/src/androidTest/fixtures/epub/README.md
new file mode 100644
index 0000000..01b7040
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/README.md
@@ -0,0 +1,48 @@
+# EPUB UI Test Fixture
+
+`reader_test_book.epub` is a deterministic EPUB fixture for Android instrumentation and UI tests.
+
+Use it for reader flows that need stable EPUB content:
+
+- open/import EPUB through a `content://` URI
+- restore reading position
+- navigate with chapter anchors
+- create or verify bookmarks
+- search unique fixture markers
+- exercise highlight and CFI-related flows
+
+The generated EPUB is stored at:
+
+`app/src/androidTest/assets/epub/reader_test_book.epub`
+
+The source files live in:
+
+`app/src/androidTest/fixtures/epub/reader_test_book`
+
+Regenerate the EPUB after editing the source:
+
+```powershell
+python app/src/androidTest/fixtures/epub/build_reader_test_book.py
+```
+
+Tests should copy the asset from the instrumentation context, not the target app context:
+
+```kotlin
+val testContext = InstrumentationRegistry.getInstrumentation().context
+testContext.assets.open("epub/reader_test_book.epub")
+```
+
+Current basic UI coverage using this fixture lives in:
+
+`app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt`
+
+Stable markers intentionally embedded in the book:
+
+- `POSITION_TARGET_ALPHA`
+- `HIGHLIGHT_TARGET_BRAVO`
+- `CFI_TARGET_CHARLIE`
+- `SEARCH_TARGET_DELTA`
+- `BOOKMARK_TARGET_ECHO`
+- `POSITION_TARGET_FOXTROT`
+- `ANNOTATION_TARGET_GOLF`
+- `CFI_TARGET_HOTEL`
diff --git a/app/src/androidTest/fixtures/epub/build_reader_test_book.py b/app/src/androidTest/fixtures/epub/build_reader_test_book.py
new file mode 100644
index 0000000..f5ab2fb
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/build_reader_test_book.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from pathlib import Path
+from zipfile import ZIP_DEFLATED, ZIP_STORED, ZipFile, ZipInfo
+
+
+ROOT = Path(__file__).resolve().parent
+SOURCE_DIR = ROOT / "reader_test_book"
+OUTPUT = ROOT.parents[1] / "assets" / "epub" / "reader_test_book.epub"
+FIXED_TIMESTAMP = (2026, 1, 1, 0, 0, 0)
+
+
+def add_file(epub: ZipFile, source: Path, archive_name: str, compression: int) -> None:
+ info = ZipInfo(archive_name, FIXED_TIMESTAMP)
+ info.compress_type = compression
+ info.external_attr = 0o644 << 16
+ epub.writestr(info, source.read_bytes())
+
+
+def main() -> None:
+ OUTPUT.parent.mkdir(parents=True, exist_ok=True)
+
+ with ZipFile(OUTPUT, "w") as epub:
+ info = ZipInfo("mimetype", FIXED_TIMESTAMP)
+ info.compress_type = ZIP_STORED
+ info.external_attr = 0o644 << 16
+ epub.writestr(info, b"application/epub+zip")
+
+ for source in sorted(SOURCE_DIR.rglob("*")):
+ if not source.is_file() or source.name == "mimetype":
+ continue
+ archive_name = source.relative_to(SOURCE_DIR).as_posix()
+ add_file(epub, source, archive_name, ZIP_DEFLATED)
+
+ print(f"Wrote {OUTPUT}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml b/app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml
new file mode 100644
index 0000000..fe5cbeb
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/META-INF/container.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml
new file mode 100644
index 0000000..d17d58f
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-01.xhtml
@@ -0,0 +1,26 @@
+
+
+
+
+ Chapter One
+
+
+
+
+ Chapter One: Stable Opening
+ This EPUB is intentionally plain so Android UI tests can rely on stable text, stable IDs, and stable chapter order.
+ POSITION_TARGET_ALPHA appears near the start of chapter one. Use this marker for first-position and restore-position checks.
+ HIGHLIGHT_TARGET_BRAVO is a short highlight target. It is surrounded by ordinary words so selection handles have context.
+ CFI_TARGET_CHARLIE sits inside a paragraph with a fixed element id. It can be used for CFI and locator assertions.
+ Chapter one filler paragraph 01 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 02 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 03 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 04 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 05 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 06 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 07 keeps the document tall enough for scroll and pagination tests.
+ Chapter one filler paragraph 08 keeps the document tall enough for scroll and pagination tests.
+ END_OF_CHAPTER_ONE_MARKER
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml
new file mode 100644
index 0000000..c5acd90
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-02.xhtml
@@ -0,0 +1,25 @@
+
+
+
+
+ Chapter Two
+
+
+
+
+ Chapter Two: Search And Bookmarks
+ SEARCH_TARGET_DELTA appears exactly once in the book. Use it for search result navigation.
+ BOOKMARK_TARGET_ECHO is positioned near the top of chapter two for bookmark add, list, and return flows.
+ POSITION_TARGET_FOXTROT is lower in chapter two and is useful for persistence tests after a chapter change.
+ Chapter two filler paragraph 01 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 02 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 03 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 04 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 05 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 06 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 07 provides enough height for gesture based navigation.
+ Chapter two filler paragraph 08 provides enough height for gesture based navigation.
+ END_OF_CHAPTER_TWO_MARKER
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml
new file mode 100644
index 0000000..73b68a0
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/chapters/chapter-03.xhtml
@@ -0,0 +1,24 @@
+
+
+
+
+ Chapter Three
+
+
+
+
+ Chapter Three: Annotation Targets
+ ANNOTATION_TARGET_GOLF is reserved for tests that verify highlight or note persistence across app restarts.
+ CFI_TARGET_HOTEL is a second fixed CFI target in a later chapter.
+ The following SVG is local to the EPUB and can be used later for image rendering checks.
+
+
+ Local SVG image for EPUB resource resolution.
+
+ Chapter three filler paragraph 01 rounds out the fixture.
+ Chapter three filler paragraph 02 rounds out the fixture.
+ Chapter three filler paragraph 03 rounds out the fixture.
+ END_OF_CHAPTER_THREE_MARKER
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg
new file mode 100644
index 0000000..730dbff
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/images/fixture-diagram.svg
@@ -0,0 +1,9 @@
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml
new file mode 100644
index 0000000..14a8470
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/nav.xhtml
@@ -0,0 +1,24 @@
+
+
+
+
+ Reader Android UI Test Book
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf
new file mode 100644
index 0000000..7bdfd8f
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/package.opf
@@ -0,0 +1,23 @@
+
+
+
+ urn:uuid:reader-android-ui-test-epub
+ Reader Android UI Test Book
+ Reader Test Fixtures
+ en
+ 2026-01-01T00:00:00Z
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css
new file mode 100644
index 0000000..5799725
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/OEBPS/styles/reader-test.css
@@ -0,0 +1,21 @@
+body {
+ font-family: serif;
+ line-height: 1.5;
+}
+
+h1 {
+ font-size: 1.4em;
+}
+
+p {
+ margin: 0 0 1em 0;
+}
+
+.fixture-note {
+ border-left: 0.25em solid #4a90e2;
+ padding-left: 0.75em;
+}
+
+.target {
+ font-weight: bold;
+}
diff --git a/app/src/androidTest/fixtures/epub/reader_test_book/mimetype b/app/src/androidTest/fixtures/epub/reader_test_book/mimetype
new file mode 100644
index 0000000..403c4f0
--- /dev/null
+++ b/app/src/androidTest/fixtures/epub/reader_test_book/mimetype
@@ -0,0 +1 @@
+application/epub+zip
diff --git a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt b/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
index d7fd42a..e966b22 100644
--- a/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/AppNavigationTest.kt
@@ -1,146 +1,117 @@
-// AppNavigationTest.kt
package com.aryan.reader
-import android.net.Uri
-import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
-import androidx.compose.material3.windowsizeclass.WindowSizeClass
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.test.junit4.createComposeRule
-import androidx.compose.ui.unit.DpSize
-import androidx.compose.ui.unit.dp
-import androidx.navigation.compose.ComposeNavigator
-import androidx.navigation.testing.TestNavHostController
-import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.aryan.reader.epub.EpubBook
-import kotlinx.coroutines.flow.MutableStateFlow
-import org.junit.Assert.assertEquals
-import org.junit.Before
-import org.junit.Rule
+import com.aryan.reader.shared.FileType
+import com.aryan.reader.shared.ReaderFeatureSurface
+import com.aryan.reader.shared.ReaderPlatform
+import com.aryan.reader.shared.SharedFileCapabilities
+import com.google.common.truth.Truth.assertThat
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class AppNavigationTest {
- @get:Rule
- val composeTestRule = createComposeRule()
+ @Test
+ fun appDestinations_useStableReaderRoutes() {
+ assertThat(AppDestinations.MAIN_ROUTE).isEqualTo("main")
+ assertThat(AppDestinations.PDF_VIEWER_ROUTE).isEqualTo("pdf_viewer")
+ assertThat(AppDestinations.EPUB_READER_ROUTE).isEqualTo("epub_reader")
+ }
- private lateinit var navController: TestNavHostController
- private val fakeUiState = MutableStateFlow(ReaderScreenState())
+ @Test
+ fun androidReaderSurface_mapsPdfBackedTypesToPdfViewer() {
+ val mappedSurfaces = listOf(
+ FileType.PDF,
+ FileType.CBZ,
+ FileType.CBR,
+ FileType.CB7,
+ FileType.CBT,
+ FileType.PPTX
+ ).associateWith { it.readerSurfaceOnAndroid() }
- // Mock ViewModel that uses the fake state
- private val fakeViewModel: MainViewModel = object : MainViewModel(
- ApplicationProvider.getApplicationContext()
- ) {
- override val uiState = fakeUiState
- override fun clearSelectedFile() {
- fakeUiState.value = fakeUiState.value.copy(
- selectedFileType = null,
- selectedPdfUri = null,
- selectedEpubBook = null
+ assertThat(mappedSurfaces).containsExactly(
+ FileType.PDF, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.CBZ, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.CBR, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.CB7, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.CBT, ReaderFeatureSurface.PDF_VIEWER,
+ FileType.PPTX, ReaderFeatureSurface.PDF_VIEWER
+ )
+ }
+
+ @Test
+ fun androidReaderSurface_mapsTextBackedTypesToEpubReader() {
+ val mappedSurfaces = listOf(
+ FileType.EPUB,
+ FileType.MOBI,
+ FileType.MD,
+ FileType.TXT,
+ FileType.HTML,
+ FileType.FB2,
+ FileType.DOCX,
+ FileType.ODT,
+ FileType.FODT
+ ).associateWith { it.readerSurfaceOnAndroid() }
+
+ assertThat(mappedSurfaces).containsExactly(
+ FileType.EPUB, ReaderFeatureSurface.EPUB_READER,
+ FileType.MOBI, ReaderFeatureSurface.EPUB_READER,
+ FileType.MD, ReaderFeatureSurface.EPUB_READER,
+ FileType.TXT, ReaderFeatureSurface.EPUB_READER,
+ FileType.HTML, ReaderFeatureSurface.EPUB_READER,
+ FileType.FB2, ReaderFeatureSurface.EPUB_READER,
+ FileType.DOCX, ReaderFeatureSurface.EPUB_READER,
+ FileType.ODT, ReaderFeatureSurface.EPUB_READER,
+ FileType.FODT, ReaderFeatureSurface.EPUB_READER
+ )
+ }
+
+ @Test
+ fun androidReaderSurface_returnsNullForUnknownFileType() {
+ assertThat(FileType.UNKNOWN.readerSurfaceOnAndroid()).isNull()
+ }
+
+ @Test
+ fun appNavBackInterceptor_onlyHandlesResumedNonReaderBackStackEntries() {
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
+ hasPreviousBackStackEntry = true,
+ isCurrentEntryResumed = true
)
- }
- }
-
- @OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
- @Before
- fun setup() {
- composeTestRule.setContent {
- navController = TestNavHostController(LocalContext.current)
- navController.navigatorProvider.addNavigator(ComposeNavigator())
- AppNavigation(
- navController = navController,
- windowSizeClass = WindowSizeClass.calculateFromSize(DpSize(400.dp, 800.dp)),
- viewModel = fakeViewModel
+ ).isTrue()
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.MAIN_ROUTE,
+ hasPreviousBackStackEntry = true,
+ isCurrentEntryResumed = true
)
- }
- }
-
- @Test
- fun appNavigation_defaultStartDestination_isMainRoute() {
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
- }
-
- @Test
- fun appNavigation_whenPdfSelected_navigatesToPdfViewer() {
- // Trigger state change
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.PDF,
- selectedPdfUri = Uri.parse("content://dummy.pdf")
- )
-
- // Let compose recompose and run LaunchedEffect
- composeTestRule.waitForIdle()
-
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
- }
-
- @Test
- fun appNavigation_whenPptxSelected_navigatesToPdfViewer() {
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.PPTX,
- selectedPdfUri = Uri.parse("content://dummy.pptx")
- )
-
- composeTestRule.waitForIdle()
-
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.PDF_VIEWER_ROUTE, currentRoute)
- }
-
- @Test
- fun appNavigation_whenEpubSelected_navigatesToEpubReader() {
- // Trigger state change
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.EPUB,
- selectedEpubBook = EpubBook(
- fileName = "dummy.epub",
- title = "Dummy Book",
- author = "Author",
- language = "en",
- coverImage = null
+ ).isFalse()
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.PDF_VIEWER_ROUTE,
+ hasPreviousBackStackEntry = true,
+ isCurrentEntryResumed = true
)
- )
-
- composeTestRule.waitForIdle()
-
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.EPUB_READER_ROUTE, currentRoute)
+ ).isFalse()
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
+ hasPreviousBackStackEntry = false,
+ isCurrentEntryResumed = true
+ )
+ ).isFalse()
+ assertThat(
+ shouldInterceptAppNavBack(
+ currentRoute = AppDestinations.PRO_SCREEN_ROUTE,
+ hasPreviousBackStackEntry = true,
+ isCurrentEntryResumed = false
+ )
+ ).isFalse()
}
- @Test
- fun appNavigation_whenFileCleared_navigatesBackToMain() {
- // First, navigate to PDF viewer
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.PDF,
- selectedPdfUri = Uri.parse("content://dummy.pdf")
- )
- composeTestRule.waitForIdle()
- assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
-
- // Then, trigger the clear action (simulating onNavigateBack)
- fakeViewModel.clearSelectedFile()
- composeTestRule.waitForIdle()
-
- val currentRoute = navController.currentBackStackEntry?.destination?.route
- assertEquals(AppDestinations.MAIN_ROUTE, currentRoute)
- }
-
- @Test
- fun appNavigation_whenUnknownFileTypeSelected_navigatesBackToMain() {
- fakeUiState.value = ReaderScreenState(
- selectedFileType = FileType.PDF,
- selectedPdfUri = Uri.parse("content://dummy.pdf")
- )
- composeTestRule.waitForIdle()
- assertEquals(AppDestinations.PDF_VIEWER_ROUTE, navController.currentBackStackEntry?.destination?.route)
-
- fakeUiState.value = ReaderScreenState(selectedFileType = FileType.UNKNOWN)
- composeTestRule.waitForIdle()
-
- assertEquals(AppDestinations.MAIN_ROUTE, navController.currentBackStackEntry?.destination?.route)
+ private fun FileType.readerSurfaceOnAndroid(): ReaderFeatureSurface? {
+ return SharedFileCapabilities.surfaceFor(this, ReaderPlatform.ANDROID)
}
}
diff --git a/app/src/androidTest/java/com/aryan/reader/HomeRecentFileCardTest.kt b/app/src/androidTest/java/com/aryan/reader/HomeRecentFileCardTest.kt
new file mode 100644
index 0000000..5422300
--- /dev/null
+++ b/app/src/androidTest/java/com/aryan/reader/HomeRecentFileCardTest.kt
@@ -0,0 +1,126 @@
+package com.aryan.reader
+
+import android.content.Context
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTouchInput
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.aryan.reader.data.RecentFileItem
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class HomeRecentFileCardTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+
+ @Test
+ fun recentFileCardShowsProgressAndUnavailableState() {
+ val item = recentBook(
+ bookId = "home_unavailable_epub",
+ title = "Unavailable Field Guide",
+ author = "Casey Example",
+ progress = 42f,
+ isAvailable = false
+ )
+
+ setRecentFileCard(item = item)
+
+ composeTestRule.onNodeWithTag("HomeRecentFileCard_home_unavailable_epub").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Unavailable Field Guide").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Casey Example").assertIsDisplayed()
+ composeTestRule.onNodeWithText("42%").assertIsDisplayed()
+ composeTestRule.onAllNodesWithContentDescription(text(R.string.not_available_locally))[0]
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun recentFileCardClickLongClickAndSelectedOverlayWork() {
+ val item = recentBook(
+ bookId = "home_selected_pdf",
+ title = "Selected Position Notes",
+ author = "Morgan Example",
+ progress = 7f
+ )
+ var clicked = false
+ var longClicked = false
+
+ setRecentFileCard(
+ item = item,
+ isSelected = true,
+ onClick = { clicked = true },
+ onLongClick = { longClicked = true }
+ )
+
+ composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_selected))[0]
+ .assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performClick()
+ composeTestRule.onNodeWithTag("HomeRecentFileCard_home_selected_pdf").performTouchInput {
+ down(center)
+ advanceEventTime(600)
+ up()
+ }
+
+ assertThat(clicked).isTrue()
+ assertThat(longClicked).isTrue()
+ }
+
+ private fun setRecentFileCard(
+ item: RecentFileItem,
+ isSelected: Boolean = false,
+ isPinned: Boolean = false,
+ onClick: () -> Unit = {},
+ onLongClick: () -> Unit = {}
+ ) {
+ composeTestRule.setContent {
+ MaterialTheme {
+ RecentFileCard(
+ item = item,
+ isSelected = isSelected,
+ isPinned = isPinned,
+ onClick = onClick,
+ onLongClick = onLongClick,
+ isDownloading = false,
+ usePdfFileNameAsDisplayName = false
+ )
+ }
+ }
+ }
+
+ private fun recentBook(
+ bookId: String,
+ title: String,
+ author: String,
+ progress: Float,
+ isAvailable: Boolean = true
+ ): RecentFileItem {
+ return RecentFileItem(
+ bookId = bookId,
+ uriString = "content://home-test/$bookId",
+ type = FileType.EPUB,
+ displayName = "$bookId.epub",
+ timestamp = 1_000L,
+ title = title,
+ author = author,
+ progressPercentage = progress,
+ isRecent = true,
+ isAvailable = isAvailable
+ )
+ }
+
+ private fun text(resId: Int): String {
+ return context.getString(resId)
+ }
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt b/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt
new file mode 100644
index 0000000..a674c3f
--- /dev/null
+++ b/app/src/androidTest/java/com/aryan/reader/LibraryScreenContentTest.kt
@@ -0,0 +1,398 @@
+package com.aryan.reader
+
+import android.content.Context
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithContentDescription
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.performTouchInput
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.aryan.reader.data.RecentFileItem
+import com.aryan.reader.data.TagEntity
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalFoundationApi::class)
+@RunWith(AndroidJUnit4::class)
+class LibraryScreenContentTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private val focusTag = TagEntity(id = "tag_focus", name = "Focus", color = null, createdAt = 1L)
+ private val libraryBooks = listOf(
+ libraryBook(
+ bookId = "pdf_beta",
+ type = FileType.PDF,
+ displayName = "beta.pdf",
+ title = "Beta Manual",
+ author = "Mira Example",
+ timestamp = 3_000L,
+ progress = 84f
+ ),
+ libraryBook(
+ bookId = "epub_gamma",
+ type = FileType.EPUB,
+ displayName = "gamma.epub",
+ title = "Gamma Field Notes",
+ author = "Nora Example",
+ timestamp = 2_000L,
+ progress = 47f,
+ tags = listOf(focusTag)
+ ),
+ libraryBook(
+ bookId = "epub_alpha",
+ type = FileType.EPUB,
+ displayName = "alpha.epub",
+ title = "Alpha Orchard",
+ author = "Zara Example",
+ timestamp = 1_000L,
+ progress = 12f,
+ tags = listOf(focusTag)
+ )
+ )
+
+ @Test
+ fun searchFiltersAndClearRestoresLibraryList() {
+ setLibraryContent()
+
+ composeTestRule.onNodeWithText("Beta Manual").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
+ composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Gamma")
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
+ }
+ assertNoText("Alpha Orchard")
+ assertNoText("Beta Manual")
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
+ composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_search)).performClick()
+ composeTestRule.onNodeWithText(text(R.string.library_title)).assertIsDisplayed()
+ }
+
+ @Test
+ fun searchMatchesAuthorAndTagNames() {
+ setLibraryContent()
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.action_search)).performClick()
+ composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Zara")
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty()
+ }
+ assertNoText("Gamma Field Notes")
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_clear_query)).performClick()
+ composeTestRule.onNodeWithTag("LibrarySearchTextField").performTextInput("Focus")
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Alpha Orchard").fetchSemanticsNodes().isNotEmpty() &&
+ composeTestRule.onAllNodesWithText("Gamma Field Notes").fetchSemanticsNodes().isNotEmpty()
+ }
+ assertNoText("Beta Manual")
+ }
+
+ @Test
+ fun activeFileTypeFilterChipCanBeCleared() {
+ setLibraryContent(initialFilters = LibraryFilters(fileTypes = setOf(FileType.EPUB)))
+
+ composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
+ assertNoText("Beta Manual")
+
+ composeTestRule.onNodeWithText(text(R.string.filter_types, FileType.EPUB.name)).performClick()
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText("Beta Manual").fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ @Test
+ fun tagAndReadStatusFiltersUseSharedLibraryRules() {
+ setLibraryContent(
+ initialFilters = LibraryFilters(
+ tagIds = setOf(focusTag.id),
+ readStatus = ReadStatusFilter.IN_PROGRESS
+ )
+ )
+
+ composeTestRule.onNodeWithText("Alpha Orchard").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Gamma Field Notes").assertIsDisplayed()
+ assertNoText("Beta Manual")
+ composeTestRule.onNodeWithText(text(R.string.filter_tags, focusTag.name)).assertIsDisplayed()
+ composeTestRule.onNodeWithText(
+ text(R.string.filter_status, text(ReadStatusFilter.IN_PROGRESS.labelRes))
+ ).assertIsDisplayed()
+ }
+
+ @Test
+ fun clearSelectionReturnsToNormalToolbar() {
+ setLibraryContent()
+
+ composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
+ down(center)
+ advanceEventTime(600)
+ up()
+ }
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.clear_selection)).performClick()
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText(text(R.string.library_title)).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ @Test
+ fun sortMenuSelectionReordersLibraryItems() {
+ setLibraryContent()
+
+ assertBookAbove("pdf_beta", "epub_alpha")
+
+ composeTestRule.onNodeWithTag("LibrarySortButton").performClick()
+ composeTestRule.onNodeWithText(text(SortOrder.TITLE_ASC.labelRes)).performClick()
+
+ composeTestRule.waitUntil(5_000) {
+ runCatching {
+ bookTop("epub_alpha") < bookTop("pdf_beta")
+ }.getOrDefault(false)
+ }
+ assertBookAbove("epub_alpha", "pdf_beta")
+ }
+
+ @Test
+ fun longPressBookShowsContextualToolbarActions() {
+ var tagClicked = false
+ var pinClicked = false
+ var infoClicked = false
+ var selectAllClicked = false
+ var deleteClicked = false
+
+ setLibraryContent(
+ onTagClick = { tagClicked = true },
+ onPinClick = { pinClicked = true },
+ onInfoClick = { infoClicked = true },
+ onSelectAllClick = { selectAllClicked = true },
+ onDeleteClick = { deleteClicked = true }
+ )
+
+ composeTestRule.onNodeWithTag("LibraryBookItem_epub_alpha").performTouchInput {
+ down(center)
+ advanceEventTime(600)
+ up()
+ }
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText(text(R.string.items_selected_count, 1)).fetchSemanticsNodes().isNotEmpty()
+ }
+
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_tag)).performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.pin_unpin)).performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.info)).performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.select_all)).performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.action_delete)).performClick()
+
+ assertThat(tagClicked).isTrue()
+ assertThat(pinClicked).isTrue()
+ assertThat(infoClicked).isTrue()
+ assertThat(selectAllClicked).isTrue()
+ assertThat(deleteClicked).isTrue()
+ }
+
+ @Test
+ fun shelvesTabShowsShelfRowsAndNewShelfAction() {
+ val shelf = Shelf(
+ id = "manual_favorites",
+ name = "Manual Favorites",
+ type = ShelfType.MANUAL,
+ books = listOf(libraryBooks[0], libraryBooks[1])
+ )
+ var clickedShelfId: String? = null
+ var longClickedShelfId: String? = null
+ var newShelfClicked = false
+
+ setLibraryContent(
+ initialPage = 1,
+ shelves = listOf(shelf),
+ onShelfClick = { clickedShelfId = it.id },
+ onShelfLongClick = { longClickedShelfId = it.id },
+ onNewShelfClick = { newShelfClicked = true }
+ )
+
+ composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").assertIsDisplayed()
+ composeTestRule.onNodeWithText("Manual Favorites").assertIsDisplayed()
+
+ composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performClick()
+ composeTestRule.onNodeWithTag("ShelfItem_manual_favorites").performTouchInput {
+ down(center)
+ advanceEventTime(600)
+ up()
+ }
+ composeTestRule.onNodeWithTag("LibraryNewShelfFab").performClick()
+
+ assertThat(clickedShelfId).isEqualTo("manual_favorites")
+ assertThat(longClickedShelfId).isEqualTo("manual_favorites")
+ assertThat(newShelfClicked).isTrue()
+ }
+
+ private fun setLibraryContent(
+ initialFilters: LibraryFilters = LibraryFilters(),
+ initialPage: Int = 0,
+ shelves: List = emptyList(),
+ onTagClick: () -> Unit = {},
+ onPinClick: () -> Unit = {},
+ onInfoClick: () -> Unit = {},
+ onSelectAllClick: () -> Unit = {},
+ onDeleteClick: () -> Unit = {},
+ onShelfClick: (Shelf) -> Unit = {},
+ onShelfLongClick: (Shelf) -> Unit = {},
+ onNewShelfClick: () -> Unit = {}
+ ) {
+ val searchQuery = mutableStateOf("")
+ val isSearchActive = mutableStateOf(false)
+ val filters = mutableStateOf(initialFilters)
+ val sortOrder = mutableStateOf(SortOrder.RECENT)
+ val selectedItems = mutableStateOf(emptySet())
+
+ composeTestRule.setContent {
+ val pagerState = rememberPagerState(
+ initialPage = initialPage,
+ pageCount = { 3 }
+ )
+ val visibleBooks = sortFiles(
+ applyLibraryFilters(
+ filterBySearch(libraryBooks, searchQuery.value),
+ filters.value
+ ),
+ sortOrder.value
+ )
+
+ MaterialTheme {
+ LibraryScreenContent(
+ tabTitles = listOf(
+ text(R.string.tab_all_books),
+ text(R.string.tab_shelves),
+ text(R.string.tab_folders)
+ ),
+ recentFiles = visibleBooks,
+ rawLibraryFiles = libraryBooks,
+ shelves = shelves,
+ selectedItems = selectedItems.value,
+ selectedShelves = emptySet(),
+ sortOrder = sortOrder.value,
+ libraryFilters = filters.value,
+ allTags = listOf(focusTag),
+ pinnedLibraryBookIds = emptySet(),
+ pagerState = pagerState,
+ scope = rememberCoroutineScope(),
+ searchQuery = searchQuery.value,
+ isSearchActive = isSearchActive.value,
+ onSearchQueryChange = { searchQuery.value = it },
+ onSearchActiveChange = { isSearchActive.value = it },
+ onSortOrderChange = { sortOrder.value = it },
+ onFilterClick = {},
+ onClearFilters = { filters.value = LibraryFilters() },
+ onRemoveFilter = { filters.value = it },
+ onTagClick = onTagClick,
+ onPinClick = onPinClick,
+ onClearSelection = { selectedItems.value = emptySet() },
+ onItemClick = {},
+ onItemLongClick = { item -> selectedItems.value = setOf(item) },
+ onInfoClick = onInfoClick,
+ onDeleteClick = onDeleteClick,
+ onSelectAllClick = onSelectAllClick,
+ onShelfClick = onShelfClick,
+ onShelfLongClick = onShelfLongClick,
+ onClearShelfSelection = {},
+ onDeleteShelves = {},
+ onNewShelfClick = onNewShelfClick,
+ onSelectFileClick = {},
+ onScanNowClick = {},
+ onSyncMetadataClick = {},
+ onSelectSyncFolderClick = {},
+ onEditFolderFiltersClick = { _, _ -> },
+ onDisconnectSyncFolderClick = {},
+ downloadingBookIds = emptySet(),
+ lastFolderScanTime = null,
+ isLoading = false,
+ isRefreshing = false,
+ syncedFolders = emptyList(),
+ onRemoveFolderClick = {},
+ onFolderLocalSyncChange = { _, _, _ -> },
+ onOpdsBookDownloaded = { _, _ -> },
+ onStreamOpdsBook = { _, _ -> },
+ onDeleteCatalogStreams = {},
+ onSettingsClick = {},
+ usePdfFileNameAsDisplayName = false
+ )
+ }
+ }
+ }
+
+ private fun libraryBook(
+ bookId: String,
+ type: FileType,
+ displayName: String,
+ title: String,
+ author: String,
+ timestamp: Long,
+ progress: Float,
+ tags: List = emptyList()
+ ): RecentFileItem {
+ return RecentFileItem(
+ bookId = bookId,
+ uriString = "content://library-test/$bookId",
+ type = type,
+ displayName = displayName,
+ timestamp = timestamp,
+ title = title,
+ author = author,
+ progressPercentage = progress,
+ isRecent = true,
+ isAvailable = true,
+ fileSize = timestamp * 10,
+ tags = tags
+ )
+ }
+
+ private fun assertBookAbove(upperBookId: String, lowerBookId: String) {
+ assertThat(bookTop(upperBookId)).isLessThan(bookTop(lowerBookId))
+ }
+
+ private fun assertNoText(value: String) {
+ assertThat(composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes()).isEmpty()
+ }
+
+ private fun bookTop(bookId: String): Float {
+ return composeTestRule
+ .onNodeWithTag("LibraryBookItem_$bookId")
+ .fetchSemanticsNode()
+ .boundsInRoot
+ .top
+ }
+
+ private fun text(resId: Int, vararg args: Any): String {
+ return context.getString(resId, *args)
+ }
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt b/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt
index cf9996f..68e6283 100644
--- a/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/epubreader/ChapterWebViewBridgeTest.kt
@@ -1,13 +1,19 @@
package com.aryan.reader.epubreader
import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Rule
import org.junit.Test
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
@OptIn(ExperimentalCoroutinesApi::class)
class ChapterWebViewBridgeTest {
@@ -20,7 +26,8 @@ class ChapterWebViewBridgeTest {
var receivedCfi = ""
val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi },
- onCfiForBookmarkReady = {}
+ onCfiForBookmarkReady = {},
+ onScrollFinishedCallback = {}
)
val cfi = "/4/2[chapter1]/6:10"
@@ -39,7 +46,8 @@ class ChapterWebViewBridgeTest {
var receivedCfi = ""
val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi },
- onCfiForBookmarkReady = {}
+ onCfiForBookmarkReady = {},
+ onScrollFinishedCallback = {}
)
val invalidJson = "this is not json"
@@ -54,7 +62,8 @@ class ChapterWebViewBridgeTest {
var receivedCfi: String? = null
val bridge = CfiJsBridge(
onCfiReady = { cfi -> receivedCfi = cfi },
- onCfiForBookmarkReady = {}
+ onCfiForBookmarkReady = {},
+ onScrollFinishedCallback = {}
)
val jsonResponse = JSONObject().apply {
@@ -69,29 +78,51 @@ class ChapterWebViewBridgeTest {
}
@Test
- fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() = runTest {
+ fun ttsJsBridge_onStructuredTextExtracted_callsHandlerWithJson() {
+ val latch = CountDownLatch(1)
var receivedJson: String? = null
- val bridge = TtsJsBridge(
- scope = this,
- ttsStructuredTextHandler = { json -> receivedJson = json }
- )
- val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
- bridge.onStructuredTextExtracted(jsonPayload)
- advanceUntilIdle()
- assertThat(receivedJson).isEqualTo(jsonPayload)
+ val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ try {
+ val bridge = TtsJsBridge(
+ scope = bridgeScope,
+ ttsStructuredTextHandler = { json ->
+ receivedJson = json
+ latch.countDown()
+ }
+ )
+ val jsonPayload = "[{\"text\":\"Hello world\",\"cfi\":\"/4/2\"}]"
+
+ bridge.onStructuredTextExtracted(jsonPayload)
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
+ assertThat(receivedJson).isEqualTo(jsonPayload)
+ } finally {
+ bridgeScope.cancel()
+ }
}
@Test
- fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() = runTest {
+ fun ttsJsBridge_onStructuredTextExtracted_withEmptyJson_callsHandlerWithEmptyArray() {
+ val latch = CountDownLatch(1)
var receivedJson: String? = null
- val bridge = TtsJsBridge(
- scope = this,
- ttsStructuredTextHandler = { json -> receivedJson = json }
- )
- val jsonPayload = ""
- bridge.onStructuredTextExtracted(jsonPayload)
- advanceUntilIdle()
- assertThat(receivedJson).isEqualTo("[]")
+ val bridgeScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
+ try {
+ val bridge = TtsJsBridge(
+ scope = bridgeScope,
+ ttsStructuredTextHandler = { json ->
+ receivedJson = json
+ latch.countDown()
+ }
+ )
+ val jsonPayload = ""
+
+ bridge.onStructuredTextExtracted(jsonPayload)
+
+ assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue()
+ assertThat(receivedJson).isEqualTo("[]")
+ } finally {
+ bridgeScope.cancel()
+ }
}
@Test
@@ -153,4 +184,4 @@ class ChapterWebViewBridgeTest {
advanceUntilIdle()
assertThat(receivedContent).isEqualTo(content)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt b/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
index a310b2d..28d8d05 100644
--- a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
@@ -1,50 +1,43 @@
-// app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderLogicTest.kt
package com.aryan.reader.epubreader
import android.content.Context
-import timber.log.Timber
-import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
-import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
-import com.aryan.reader.SearchResult
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.EpubChapter
import com.google.common.truth.Truth.assertThat
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.runBlocking
-import kotlinx.coroutines.withContext
-import org.jsoup.Jsoup
+import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
-import kotlin.math.max
-import kotlin.math.min
@RunWith(AndroidJUnit4::class)
class EpubReaderLogicTest {
private lateinit var context: Context
private lateinit var testDir: File
- private lateinit var mockEpubBook: EpubBook
+ private lateinit var testBook: EpubBook
@Before
fun setup() {
context = ApplicationProvider.getApplicationContext()
- testDir = File(context.cacheDir, "test_epub").apply { mkdirs() }
+ testDir = File(context.cacheDir, "test_epub_search").apply {
+ deleteRecursively()
+ mkdirs()
+ }
- // Create dummy chapter files
- val chapter1File = File(testDir, "chapter1.html")
- chapter1File.writeText("A simple Test case.
")
+ val chapter1File = File(testDir, "chapter1.html").apply {
+ writeText("A simple Test case.
")
+ }
+ val chapter2File = File(testDir, "chapter2.html").apply {
+ writeText("Another test case here.
The word Test appears twice.
")
+ }
- val chapter2File = File(testDir, "chapter2.html")
- chapter2File.writeText("Another test case here.
The word Test appears twice.
")
-
- mockEpubBook = EpubBook(
+ testBook = EpubBook(
fileName = "test.epub",
title = "Test Book",
author = "Tester",
@@ -77,86 +70,19 @@ class EpubReaderLogicTest {
testDir.deleteRecursively()
}
- private suspend fun searchEpub(book: EpubBook, query: String): List {
- val TAG = "EpubReaderLogicTest"
- Timber.d("Starting search for query: '$query'")
- return withContext(Dispatchers.IO) {
- val results = mutableListOf()
- book.chapters.forEachIndexed { chapterIndex, chapter ->
- try {
- val fullPath = "${book.extractionBasePath}/${chapter.htmlFilePath}"
- Timber.d("Chapter ${chapterIndex + 1}: Checking path '$fullPath'")
- val htmlFile = File(fullPath)
- if (!htmlFile.exists()) {
- Timber.e("File does not exist: $fullPath")
- return@forEachIndexed
- }
-
- val doc = Jsoup.parse(htmlFile, "UTF-8")
- val bodyChildren = doc.body().children().toList()
- val chunks = bodyChildren.chunked(20)
-
- chunks.forEachIndexed { chunkIndex, chunkOfElements ->
- val chunkHtml = chunkOfElements.joinToString(separator = "\n") { it.outerHtml() }
- val content = Jsoup.parse(chunkHtml).text()
- var lastIndex = -1
-
- while (true) {
- lastIndex = content.indexOf(query, startIndex = lastIndex + 1, ignoreCase = true)
- if (lastIndex == -1) break
-
- Timber.d("Found potential match for '$query' at index $lastIndex.")
- val isWordStart = lastIndex == 0 || !content[lastIndex - 1].isLetterOrDigit()
- Timber.d("Is it a word start? -> $isWordStart")
- if (isWordStart) {
- Timber.d("Match is a word start. Adding to results.")
- val snippetStart = max(0, lastIndex - 35)
- val snippetEnd = min(content.length, lastIndex + query.length + 35)
- val rawSnippet = content.substring(snippetStart, snippetEnd)
- val annotatedSnippet = buildAnnotatedString {
- append(rawSnippet)
- val highlightStart = content.indexOf(query, lastIndex, ignoreCase = true) - snippetStart
- val highlightEnd = highlightStart + query.length
- addStyle(
- style = SpanStyle(fontWeight = FontWeight.Bold),
- start = highlightStart,
- end = highlightEnd
- )
- }
- results.add(
- SearchResult(
- locationInSource = chapterIndex,
- locationTitle = chapter.title,
- snippet = annotatedSnippet,
- query = query,
- occurrenceIndexInLocation = results.count { it.locationInSource == chapterIndex },
- chunkIndex = chunkIndex
- )
- )
- }
- }
- }
- } catch (e: Exception) {
- Timber.e("Error during search in chapter ${chapter.title}", e)
- throw e
- }
- }
- Timber.d("Search finished. Total results found: ${results.size}")
- results
- }
- }
-
@Test
- fun search_findsCorrectResults() = runBlocking {
- val results = searchEpub(mockEpubBook, "case")
+ fun search_findsCorrectResults() = runTest {
+ val results = createEpubSearcher(testBook)("case")
+
assertThat(results).hasSize(2)
assertThat(results.count { it.locationTitle == "Chapter 1" }).isEqualTo(1)
assertThat(results.count { it.locationTitle == "Chapter 2" }).isEqualTo(1)
}
@Test
- fun search_isCaseInsensitive() = runBlocking {
- val results = searchEpub(mockEpubBook, "test")
+ fun search_isCaseInsensitive() = runTest {
+ val results = createEpubSearcher(testBook)("test")
+
assertThat(results).hasSize(3)
assertThat(results[0].locationTitle).isEqualTo("Chapter 1")
assertThat(results[1].locationTitle).isEqualTo("Chapter 2")
@@ -164,41 +90,19 @@ class EpubReaderLogicTest {
}
@Test
- fun search_noResultsFound() = runBlocking {
- val results = searchEpub(mockEpubBook, "nonexistent")
+ fun search_noResultsFound() = runTest {
+ val results = createEpubSearcher(testBook)("nonexistent")
+
assertThat(results).isEmpty()
}
@Test
- fun search_createsCorrectSnippetHighlight() = runBlocking {
- val query = "Test"
- mockEpubBook.chapters.first()
- val content = "A simple Test case."
- val annotatedString = buildAnnotatedStringWithHighlight(content, query)
+ fun search_createsCorrectSnippetHighlight() = runTest {
+ val result = createEpubSearcher(testBook)("Test").first()
+ val boldRanges = result.snippet.spanStyles.filter { it.item == SpanStyle(fontWeight = FontWeight.Bold) }
- val spanStyles = annotatedString.spanStyles
- assertThat(spanStyles).hasSize(1)
-
- val style = spanStyles.first().item
- assertThat(style.fontWeight).isEqualTo(FontWeight.Bold)
-
- val start = spanStyles.first().start
- val end = spanStyles.first().end
- assertThat(annotatedString.substring(start, end)).isEqualTo(query)
+ assertThat(boldRanges).hasSize(1)
+ val highlight = boldRanges.first()
+ assertThat(result.snippet.text.substring(highlight.start, highlight.end)).isEqualTo("Test")
}
-
- @Suppress("SameParameterValue")
- private fun buildAnnotatedStringWithHighlight(content: String, query: String): AnnotatedString {
- return buildAnnotatedString {
- append(content)
- val highlightStart = content.indexOf(query, ignoreCase = true)
- if (highlightStart != -1) {
- addStyle(
- style = SpanStyle(fontWeight = FontWeight.Bold),
- start = highlightStart,
- end = highlightStart + query.length
- )
- }
- }
- }
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt b/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt
new file mode 100644
index 0000000..b4402e6
--- /dev/null
+++ b/app/src/androidTest/java/com/aryan/reader/epubreader/EpubReaderScreenTest.kt
@@ -0,0 +1,818 @@
+package com.aryan.reader.epubreader
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.assertTextContains
+import androidx.compose.ui.test.click
+import androidx.compose.ui.test.hasSetTextAction
+import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onAllNodesWithContentDescription
+import androidx.compose.ui.test.onAllNodesWithTag
+import androidx.compose.ui.test.onAllNodesWithText
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.onRoot
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performTextClearance
+import androidx.compose.ui.test.performTextInput
+import androidx.compose.ui.test.performTouchInput
+import androidx.core.content.FileProvider
+import androidx.test.core.app.ActivityScenario
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.aryan.reader.FileType
+import com.aryan.reader.FileHasher
+import com.aryan.reader.MainActivity
+import com.aryan.reader.R
+import com.aryan.reader.RenderMode
+import com.aryan.reader.data.AppDatabase
+import com.aryan.reader.data.RecentFileEntity
+import com.aryan.reader.shared.EpubAnnotationSerializer
+import com.aryan.reader.shared.ReaderLocator
+import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.UUID
+
+@RunWith(AndroidJUnit4::class)
+class EpubReaderScreenTest {
+
+ @get:Rule
+ val composeTestRule = createEmptyComposeRule()
+
+ private val fixtureAssetName = "epub/reader_test_book.epub"
+ private val fixtureBookTitle = "Reader Android UI Test Book"
+ private val sanitizedFixtureBookTitle = "ReaderAndroidUITestBook"
+ private val targetContext: Context = ApplicationProvider.getApplicationContext()
+ private val instrumentationContext: Context = InstrumentationRegistry.getInstrumentation().context
+ private var currentEpubFile: File? = null
+ private var scenario: ActivityScenario? = null
+ private lateinit var fixtureBookId: String
+
+ @Before
+ fun setup() {
+ clearReaderPrefs()
+ fixtureBookId = requireNotNull(
+ runBlocking {
+ FileHasher.calculateSha256 {
+ instrumentationContext.assets.open(fixtureAssetName)
+ }
+ }
+ )
+ runBlocking {
+ AppDatabase.getDatabase(targetContext)
+ .recentFileDao()
+ .deleteFilePermanently(listOf(fixtureBookId))
+ }
+ }
+
+ @After
+ fun tearDown() {
+ scenario?.close()
+ currentEpubFile?.let {
+ if (it.exists()) it.delete()
+ }
+ }
+
+ @Test
+ fun fixtureEpub_opensReaderAndShowsBookTitle() {
+ launchFixtureReader()
+ waitForReader()
+ showReaderChrome()
+
+ waitForText(fixtureBookTitle)
+ composeTestRule.onNodeWithText(fixtureBookTitle).assertIsDisplayed()
+ assertThat(hasContentDescription(text(R.string.tooltip_search))).isTrue()
+ assertThat(hasContentDescription(text(R.string.content_desc_chapters_menu))).isTrue()
+ }
+
+ @Test
+ fun fixtureEpub_recordsInitialReadingPosition() {
+ launchFixtureReader()
+ waitForReader()
+
+ waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
+ recentFile?.lastChapterIndex != null &&
+ recentFile.locatorBlockIndex != null &&
+ recentFile.locatorCharOffset != null &&
+ recentFile.progressPercentage != null
+ }
+
+ val recentFile = readFixtureRecentFile()
+ assertThat(recentFile?.lastChapterIndex).isAtLeast(0)
+ assertThat(recentFile?.locatorBlockIndex).isAtLeast(0)
+ assertThat(recentFile?.locatorCharOffset).isAtLeast(0)
+ assertThat(recentFile?.progressPercentage).isAtLeast(0f)
+ }
+
+ @Test
+ fun fixtureEpub_restoresSeededReadingPositionWithoutResettingToStart() {
+ launchFixtureReader { fixtureUri ->
+ seedFixtureRecentFile(
+ uriString = fixtureUri.toString(),
+ chapterIndex = 1,
+ blockIndex = 1,
+ charOffset = 0,
+ progress = 45f
+ )
+ }
+ waitForReader()
+
+ waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
+ recentFile?.lastChapterIndex == 1 &&
+ recentFile.locatorBlockIndex == 1 &&
+ recentFile.locatorCharOffset == 0 &&
+ (recentFile.progressPercentage ?: 0f) >= 45f
+ }
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsFixtureChapters() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+
+ waitForText(text(R.string.tab_chapters))
+ waitForTextContaining("Chapter One")
+ waitForTextContaining("Chapter Two")
+ waitForTextContaining("Chapter Three")
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsFixtureImageCatalog() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_images))
+
+ waitForText("Fixture diagram")
+ waitForTextContaining("Chapter Three")
+ assertThat(hasContentDescription(text(R.string.content_desc_download_image))).isTrue()
+ }
+
+ @Test
+ fun fixtureEpub_searchFindsUniqueFixtureMarker() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.tooltip_search))
+ waitForTag("SearchTextField")
+
+ composeTestRule.onNodeWithTag("SearchTextField").performTextInput("SEARCH_TARGET_DELTA")
+ composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("SEARCH_TARGET_DELTA")
+
+ waitForTag("SearchResultItem_1", timeoutMillis = 20_000)
+ composeTestRule.onNodeWithTag("SearchResultItem_1").assertIsDisplayed()
+ }
+
+ @Test
+ fun fixtureEpub_searchNavigationPositionSurvivesReadingModeSwitches() {
+ launchFixtureReader()
+ waitForReader()
+
+ navigateToFixtureSearchResult("SEARCH_TARGET_DELTA", expectedChapterIndex = 1)
+ clickContentDescription(text(R.string.tooltip_close_search))
+
+ waitForRecentFile(timeoutMillis = 30_000) { recentFile ->
+ recentFile?.lastChapterIndex == 1 &&
+ recentFile.locatorBlockIndex != null &&
+ recentFile.locatorCharOffset != null &&
+ recentFile.progressPercentage != null
+ }
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_change_reading_mode))
+ clickText(text(R.string.menu_reading_mode_paginated))
+ waitForRenderMode(RenderMode.PAGINATED)
+
+ waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
+ recentFile?.lastChapterIndex == 1 &&
+ (recentFile.progressPercentage ?: 0f) > 0f
+ }
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_change_reading_mode))
+ clickText(text(R.string.menu_reading_mode_vertical_webview))
+ waitForRenderMode(RenderMode.VERTICAL_SCROLL)
+
+ waitForRecentFile(timeoutMillis = 20_000) { recentFile ->
+ recentFile?.lastChapterIndex == 1 &&
+ (recentFile.progressPercentage ?: 0f) > 0f
+ }
+ }
+
+ @Test
+ fun fixtureEpub_addsCurrentPageBookmarkPersistsAndDeletes() {
+ launchFixtureReader()
+ waitForReader()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_bookmark_this_page))
+
+ waitForFixtureBookmarks(timeoutMillis = 20_000) { bookmarksJson ->
+ parseBookmarksJson(bookmarksJson).isNotEmpty()
+ }
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_bookmarks))
+ waitForTextContaining("Chapter One")
+ assertThat(hasContentDescription(text(R.string.content_desc_more_options_bookmark))).isTrue()
+
+ clickContentDescription(text(R.string.content_desc_more_options_bookmark))
+ clickText(text(R.string.action_delete))
+ waitForText(text(R.string.dialog_delete_bookmark))
+ clickText(text(R.string.action_delete))
+
+ waitForText(text(R.string.no_bookmarks_yet))
+ waitForFixtureBookmarks(timeoutMillis = 10_000) { bookmarksJson ->
+ parseBookmarksJson(bookmarksJson).isEmpty()
+ }
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsSeededBookmarkAndSupportsRenameDelete() {
+ seedFixtureBookmark()
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_bookmarks))
+
+ waitForText("BOOKMARK_TARGET_ECHO")
+ waitForText("Chapter Two")
+
+ clickContentDescription(text(R.string.content_desc_more_options_bookmark))
+ clickText(text(R.string.action_rename))
+ waitForText(text(R.string.dialog_rename_bookmark))
+ composeTestRule.onNode(hasSetTextAction()).performTextInput("Renamed fixture bookmark")
+ clickText(text(R.string.action_save))
+ waitForText("Renamed fixture bookmark")
+
+ clickContentDescription(text(R.string.content_desc_more_options_bookmark))
+ clickText(text(R.string.action_delete))
+ waitForText(text(R.string.dialog_delete_bookmark))
+ clickText(text(R.string.action_delete))
+ waitForText(text(R.string.no_bookmarks_yet))
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsSeededAnnotationWithNoteAndFilter() {
+ seedFixtureHighlight()
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_annotations))
+
+ waitForText("ANNOTATION_TARGET_GOLF")
+ waitForText("Fixture note survives startup")
+ waitForTextContaining("Chapter Three")
+
+ clickText(text(R.string.filter_with_notes))
+ waitForText("ANNOTATION_TARGET_GOLF")
+ waitForText("Fixture note survives startup")
+ }
+
+ @Test
+ fun fixtureEpub_seededAnnotationSupportsColorNoteAndDeletePersistence() {
+ seedFixtureHighlight()
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_annotations))
+
+ waitForText("ANNOTATION_TARGET_GOLF")
+ clickContentDescription(text(R.string.content_desc_options))
+ composeTestRule.onNodeWithTag("HighlightColor_blue").performClick()
+
+ waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
+ parseHighlightsJson(highlightsJson).singleOrNull()?.color == HighlightColor.BLUE
+ }
+
+ clickContentDescription(text(R.string.content_desc_options))
+ clickText(text(R.string.menu_edit_note))
+ waitForText(text(R.string.action_save_note))
+ composeTestRule.onNode(hasSetTextAction())
+ .performTextClearance()
+ composeTestRule.onNode(hasSetTextAction())
+ .performTextInput("Updated fixture note")
+ clickText(text(R.string.action_save_note))
+
+ waitForText("Updated fixture note")
+ waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
+ parseHighlightsJson(highlightsJson).singleOrNull()?.note == "Updated fixture note"
+ }
+
+ clickContentDescription(text(R.string.content_desc_options))
+ clickText(text(R.string.action_delete))
+ waitForText(text(R.string.dialog_delete_highlight))
+ clickText(text(R.string.action_delete))
+
+ waitForText(text(R.string.no_highlights_yet))
+ waitForFixtureHighlights(timeoutMillis = 10_000) { highlightsJson ->
+ parseHighlightsJson(highlightsJson).isEmpty()
+ }
+ }
+
+ @Test
+ fun fixtureEpub_overflowSwitchesReadingModeAndTogglesPageOptions() {
+ launchFixtureReader()
+ waitForReader()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_change_reading_mode))
+ clickText(text(R.string.menu_reading_mode_paginated))
+ waitForRenderMode(RenderMode.PAGINATED)
+ composeTestRule.waitForIdle()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_tap_to_turn_pages))
+ assertReaderSettingEventually(
+ prefsName = "epub_reader_settings",
+ key = "tap_to_navigate_enabled",
+ expected = true
+ )
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_realistic_page_turns))
+ assertReaderSettingEventually(
+ prefsName = "reader_prefs",
+ key = "page_turn_animation_enabled",
+ expected = true
+ )
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_keep_screen_on))
+ assertReaderSettingEventually(
+ prefsName = "reader_prefs",
+ key = "keep_screen_on_enabled",
+ expected = true
+ )
+ }
+
+ @Test
+ fun fixtureEpub_visualOptionsSheetPersistsProgressPosition() {
+ launchFixtureReader()
+ waitForReader()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_visual_options))
+
+ waitForText(text(R.string.visual_options_system_ui))
+ waitForText(text(R.string.visual_options_progress_bar))
+ waitForText(text(R.string.visual_options_progress_bar_position))
+ clickText(text(R.string.label_top))
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .getInt("reader_page_info_position", 0) == 1
+ }
+ }
+
+ @Test
+ fun fixtureEpub_formatPanelShowsControlsAndPersistsLocalFontSize() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_text_formatting))
+ waitForText(text(R.string.section_font_alignment))
+ waitForText(text(R.string.section_layout_spacing))
+ waitForText(text(R.string.label_font_size))
+ waitForText(text(R.string.label_line_height))
+ waitForText(text(R.string.label_paragraph_gap))
+ waitForText(text(R.string.label_image_size))
+ waitForText(text(R.string.label_horizontal_margin))
+ waitForText(text(R.string.label_vertical_margin))
+
+ composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_select_mode))[0]
+ .performClick()
+ clickText(text(R.string.format_local))
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .getBoolean("format_is_local_$fixtureBookId", false)
+ }
+
+ composeTestRule.onAllNodesWithContentDescription(text(R.string.content_desc_increase))[0]
+ .performClick()
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .getFloat("local_font_size_$fixtureBookId", 1.0f) > 1.0f
+ }
+ }
+
+ @Test
+ fun fixtureEpub_fontSelectionSheetPersistsFontFamily() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_text_formatting))
+ waitForText(text(R.string.section_font_alignment))
+ clickContentDescription(text(R.string.content_desc_select_font_family))
+
+ waitForText(text(R.string.select_font))
+ waitForText(text(R.string.tab_presets))
+ waitForText(text(R.string.tab_imported))
+ clickText("Lato")
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .getString("reader_font_family", "original") == "lato"
+ }
+ }
+
+ @Test
+ fun fixtureEpub_themePanelShowsThemesAndPersistsSelection() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.tooltip_theme_desc))
+
+ waitForText(text(R.string.reading_themes))
+ waitForText(text(R.string.theme_solid_colors))
+ waitForText("Light")
+ waitForText("Dark")
+ clickText("Sepia")
+
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences("reader_prefs", Context.MODE_PRIVATE)
+ .getString(PREF_READER_THEME, "system") == "sepia"
+ }
+ }
+
+ @Test
+ fun fixtureEpub_drawerShowsEmptyBookmarkAndAnnotationStates() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_chapters_menu))
+ clickText(text(R.string.tab_bookmarks))
+ waitForText(text(R.string.no_bookmarks_yet))
+
+ clickText(text(R.string.tab_annotations))
+ waitForText(text(R.string.no_highlights_yet))
+ }
+
+ @Test
+ fun fixtureEpub_searchCanClearAndClose() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.tooltip_search))
+ waitForTag("SearchTextField")
+
+ composeTestRule.onNodeWithTag("SearchTextField")
+ .performTextInput("SEARCH_TARGET_DELTA")
+ waitForTextContaining("SEARCH_TARGET_DELTA")
+
+ clickContentDescription(text(R.string.tooltip_clear_search))
+ waitForNoContentDescription(text(R.string.tooltip_clear_search))
+
+ clickContentDescription(text(R.string.tooltip_close_search))
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ hasContentDescription(text(R.string.tooltip_search))
+ }
+ }
+
+ @Test
+ fun fixtureEpub_dictionarySettingsShowsLookupSections() {
+ launchFixtureReader()
+ waitForReader()
+
+ clickReaderControl(text(R.string.content_desc_dictionary_settings))
+
+ waitForText(text(R.string.dict_lookup_settings))
+ waitForText(text(R.string.tooltip_dictionary))
+ waitForText(text(R.string.dict_translate))
+ waitForText(text(R.string.tooltip_search))
+ }
+
+ @Test
+ fun fixtureEpub_ttsReplacementSheetShowsGlobalAndBookScopes() {
+ launchFixtureReader()
+ waitForReader()
+
+ openOverflowMenu()
+ clickText(text(R.string.menu_tts_settings))
+ clickText(text(R.string.menu_tts_word_replacements))
+
+ waitForText(text(R.string.menu_tts_word_replacements))
+ waitForText(fixtureBookTitle)
+ waitForText(text(R.string.tts_replacements_tab_global))
+ waitForText(text(R.string.tts_replacements_tab_this_book))
+ waitForText(text(R.string.tts_replacements_enable))
+ }
+
+ private fun launchFixtureReader(beforeLaunch: (Uri) -> Unit = {}) {
+ val fixtureUri = copyAndroidTestAssetToCache(fixtureAssetName)
+ beforeLaunch(fixtureUri)
+ scenario = ActivityScenario.launch(createEpubViewIntent(fixtureUri))
+ }
+
+ private fun clearReaderPrefs() {
+ listOf(
+ "epub_reader_settings",
+ "epub_reader_bookmarks",
+ "reader_prefs"
+ ).forEach { prefsName ->
+ targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
+ .edit()
+ .clear()
+ .commit()
+ }
+ targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+ .edit()
+ .putString("render_mode", "VERTICAL_SCROLL")
+ .commit()
+ }
+
+ private fun text(resId: Int): String = targetContext.getString(resId)
+
+ private fun seedFixtureBookmark() {
+ val bookmark = org.json.JSONObject().apply {
+ put("cfi", "android-locator:1:1:0")
+ put("chapterTitle", "Chapter Two")
+ put("label", org.json.JSONObject.NULL)
+ put("snippet", "BOOKMARK_TARGET_ECHO")
+ put("pageInChapter", 1)
+ put("totalPagesInChapter", 3)
+ put("chapterIndex", 1)
+ put(
+ "locator",
+ org.json.JSONObject().apply {
+ put("chapterIndex", 1)
+ put("chapterId", "chapter-02")
+ put("pageIndex", 0)
+ put("blockIndex", 1)
+ put("charOffset", 0)
+ put("textQuote", "BOOKMARK_TARGET_ECHO")
+ put("cfi", "android-locator:1:1:0")
+ }
+ )
+ }
+
+ targetContext.getSharedPreferences("epub_reader_bookmarks", Context.MODE_PRIVATE)
+ .edit()
+ .putStringSet("bookmarks_cfi_$sanitizedFixtureBookTitle", setOf(bookmark.toString()))
+ .commit()
+ }
+
+ private fun seedFixtureHighlight() {
+ val highlight = UserHighlight(
+ id = "fixture_annotation_golf",
+ cfi = "android-locator:2:1:0",
+ text = "ANNOTATION_TARGET_GOLF",
+ color = HighlightColor.GREEN,
+ chapterIndex = 2,
+ note = "Fixture note survives startup",
+ locator = ReaderLocator(
+ chapterIndex = 2,
+ chapterId = "chapter-03",
+ blockIndex = 1,
+ charOffset = 0,
+ textQuote = "ANNOTATION_TARGET_GOLF",
+ cfi = "android-locator:2:1:0"
+ )
+ )
+
+ targetContext.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
+ .edit()
+ .putString("highlights_data_$sanitizedFixtureBookTitle", highlightsToJson(listOf(highlight)))
+ .commit()
+ }
+
+ private fun seedFixtureRecentFile(
+ uriString: String,
+ chapterIndex: Int,
+ blockIndex: Int,
+ charOffset: Int,
+ progress: Float
+ ) {
+ val now = System.currentTimeMillis()
+ runBlocking {
+ AppDatabase.getDatabase(targetContext)
+ .recentFileDao()
+ .insertOrUpdateFile(
+ RecentFileEntity(
+ bookId = fixtureBookId,
+ uriString = uriString,
+ type = FileType.EPUB,
+ displayName = fixtureBookTitle,
+ timestamp = now,
+ coverImagePath = null,
+ title = fixtureBookTitle,
+ author = "Fixture Author",
+ lastChapterIndex = chapterIndex,
+ lastPage = null,
+ lastPositionCfi = "android-locator:$chapterIndex:$blockIndex:$charOffset",
+ progressPercentage = progress,
+ isRecent = true,
+ isAvailable = true,
+ lastModifiedTimestamp = now,
+ isDeleted = false,
+ locatorBlockIndex = blockIndex,
+ locatorCharOffset = charOffset,
+ bookmarks = null,
+ sourceFolderUri = null,
+ isReflowPreferred = false,
+ customName = null,
+ highlights = null,
+ fileSize = 0L,
+ fileContentModifiedTimestamp = 0L,
+ seriesName = null,
+ seriesIndex = null,
+ description = null,
+ folderTextMetadataParsed = false,
+ folderCoverMetadataParsed = false,
+ originalTitle = fixtureBookTitle,
+ originalAuthor = "Fixture Author",
+ originalSeriesName = null,
+ originalSeriesIndex = null,
+ originalDescription = null
+ )
+ )
+ }
+ }
+
+ private fun createEpubViewIntent(uri: Uri): Intent {
+ return Intent(targetContext, MainActivity::class.java).apply {
+ action = Intent.ACTION_VIEW
+ setDataAndType(uri, "application/epub+zip")
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ }
+
+ private fun copyAndroidTestAssetToCache(assetName: String): Uri {
+ val file = File(targetContext.cacheDir, "${UUID.randomUUID()}_reader_test_book.epub")
+ currentEpubFile = file
+
+ instrumentationContext.assets.open(assetName).use { inputStream ->
+ file.outputStream().use { outputStream ->
+ inputStream.copyTo(outputStream)
+ }
+ }
+
+ return FileProvider.getUriForFile(
+ targetContext,
+ "${targetContext.packageName}.provider",
+ file
+ )
+ }
+
+ private fun navigateToFixtureSearchResult(query: String, expectedChapterIndex: Int) {
+ clickReaderControl(text(R.string.tooltip_search))
+ waitForTag("SearchTextField")
+
+ composeTestRule.onNodeWithTag("SearchTextField").performTextInput(query)
+ waitForTag("SearchResultItem_$expectedChapterIndex", timeoutMillis = 20_000)
+ composeTestRule.onNodeWithTag("SearchResultItem_$expectedChapterIndex").performClick()
+ }
+
+ private fun waitForReader() {
+ waitForTag("ReaderContainer", timeoutMillis = 30_000)
+ }
+
+ private fun showReaderChrome() {
+ if (hasAnyReaderControl()) return
+
+ composeTestRule.onRoot().performTouchInput { click(center) }
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ hasAnyReaderControl()
+ }
+ }
+
+ private fun clickReaderControl(contentDescription: String) {
+ showReaderChrome()
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ hasContentDescription(contentDescription)
+ }
+ composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
+ }
+
+ private fun clickContentDescription(contentDescription: String) {
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ hasContentDescription(contentDescription)
+ }
+ composeTestRule.onAllNodesWithContentDescription(contentDescription)[0].performClick()
+ }
+
+ private fun clickText(value: String) {
+ composeTestRule.waitUntil(timeoutMillis = 10_000) {
+ composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
+ }
+ composeTestRule.onAllNodesWithText(value)[0].performClick()
+ }
+
+ private fun openOverflowMenu() {
+ clickReaderControl(text(R.string.content_desc_more_options))
+ }
+
+ private fun hasAnyReaderControl(): Boolean {
+ return hasContentDescription(text(R.string.tooltip_search)) ||
+ hasContentDescription(text(R.string.content_desc_chapters_menu)) ||
+ hasContentDescription(text(R.string.content_desc_more_options))
+ }
+
+ private fun hasContentDescription(contentDescription: String): Boolean {
+ return composeTestRule
+ .onAllNodesWithContentDescription(contentDescription)
+ .fetchSemanticsNodes()
+ .isNotEmpty()
+ }
+
+ private fun waitForTag(tag: String, timeoutMillis: Long = 10_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun waitForText(value: String, timeoutMillis: Long = 10_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ composeTestRule.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun waitForTextContaining(value: String, timeoutMillis: Long = 10_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ composeTestRule.onAllNodesWithText(value, substring = true).fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun waitForNoContentDescription(contentDescription: String, timeoutMillis: Long = 5_000) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ composeTestRule
+ .onAllNodesWithContentDescription(contentDescription)
+ .fetchSemanticsNodes()
+ .isEmpty()
+ }
+ }
+
+ private fun waitForRenderMode(expected: RenderMode) {
+ composeTestRule.waitUntil(timeoutMillis = 20_000) {
+ targetContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+ .getString("render_mode", RenderMode.VERTICAL_SCROLL.name) == expected.name
+ }
+ }
+
+ private fun assertReaderSettingEventually(
+ prefsName: String,
+ key: String,
+ expected: Boolean
+ ) {
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ targetContext.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
+ .getBoolean(key, !expected) == expected
+ }
+ }
+
+ private fun readFixtureRecentFile(): RecentFileEntity? {
+ return runBlocking {
+ AppDatabase.getDatabase(targetContext)
+ .recentFileDao()
+ .getFileByBookId(fixtureBookId)
+ }
+ }
+
+ private fun waitForRecentFile(
+ timeoutMillis: Long = 10_000,
+ predicate: (RecentFileEntity?) -> Boolean
+ ) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ predicate(readFixtureRecentFile())
+ }
+ }
+
+ private fun waitForFixtureBookmarks(
+ timeoutMillis: Long = 10_000,
+ predicate: (String?) -> Boolean
+ ) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ predicate(readFixtureRecentFile()?.bookmarks)
+ }
+ }
+
+ private fun waitForFixtureHighlights(
+ timeoutMillis: Long = 10_000,
+ predicate: (String?) -> Boolean
+ ) {
+ composeTestRule.waitUntil(timeoutMillis = timeoutMillis) {
+ predicate(readFixtureRecentFile()?.highlights)
+ }
+ }
+
+ private fun parseBookmarksJson(rawJson: String?): Set {
+ return EpubAnnotationSerializer.parseBookmarksJson(rawJson)
+ }
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt
index c6fe395..f7fe15e 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/CssParserTest.kt
@@ -17,49 +17,66 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class CssParserTest {
+ private val dummyConstraints = androidx.compose.ui.unit.Constraints()
+ private val baseFontSize = 16f
+ private val density = 1f
+
+ private fun parseTextColor(value: String): Color? {
+ val result = CssParser.parse(
+ cssContent = "p { color: $value; }",
+ cssPath = null,
+ baseFontSizeSp = baseFontSize,
+ density = density,
+ constraints = dummyConstraints,
+ isDarkTheme = false
+ )
+ return result.rules.byTag["p"]
+ ?.firstOrNull()
+ ?.style
+ ?.spanStyle
+ ?.color
+ ?.takeIf { it.isSpecified }
+ }
+
@Test
fun parseColor_handlesNamedColorsCorrectly() {
- assertThat(CssParser.parseColor("red")).isEqualTo(Color.Red)
- assertThat(CssParser.parseColor("black")).isEqualTo(Color.Black)
- assertThat(CssParser.parseColor("transparent")).isEqualTo(Color.Transparent)
+ assertThat(parseTextColor("red")).isEqualTo(Color.Red)
+ assertThat(parseTextColor("black")).isEqualTo(Color.Black)
+ assertThat(parseTextColor("transparent")).isEqualTo(Color.Transparent)
}
@Test
fun parseColor_handles3DigitHexCodes() {
- assertThat(CssParser.parseColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
+ assertThat(parseTextColor("#F0C")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles6DigitHexCodes() {
- assertThat(CssParser.parseColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
+ assertThat(parseTextColor("#FF00CC")).isEqualTo(Color(0xFFFF00CC))
}
@Test
fun parseColor_handles8DigitHexCodes() {
- assertThat(CssParser.parseColor("#80FF00CC")).isEqualTo(Color(0x80FF00CC))
+ assertThat(parseTextColor("#80FF00CC")).isEqualTo(Color(128, 255, 0, 204))
}
@Test
fun parseColor_handlesRgbFunction() {
- assertThat(CssParser.parseColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
+ assertThat(parseTextColor("rgb(255, 0, 204)")).isEqualTo(Color(255, 0, 204))
}
@Test
fun parseColor_handlesRgbaFunction() {
- assertThat(CssParser.parseColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
+ assertThat(parseTextColor("rgba(255, 0, 204, 0.5)")).isEqualTo(Color(255, 0, 204, 128))
}
@Test
fun parseColor_returnsNullForInvalidInput() {
- assertThat(CssParser.parseColor("not a color")).isNull()
- assertThat(CssParser.parseColor("#12345")).isNull()
- assertThat(CssParser.parseColor("rgb(1,2)")).isNull()
+ assertThat(parseTextColor("not a color")).isNull()
+ assertThat(parseTextColor("#12345")).isNull()
+ assertThat(parseTextColor("rgb(1,2)")).isNull()
}
- private val dummyConstraints = androidx.compose.ui.unit.Constraints()
- private val baseFontSize = 16f
- private val density = 1f
-
@Test
fun parse_handlesSimpleRule() {
val css = "p { color: red; }"
@@ -120,12 +137,12 @@ class CssParserTest {
}
p { color: black; }
""".trimIndent()
- val result = CssParser.parse(css, "/some/path/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
+ val result = CssParser.parse(css, "OEBPS/styles/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.rules.byTag).containsKey("p")
assertThat(result.fontFaces).hasSize(1)
val fontFace = result.fontFaces.first()
assertThat(fontFace.fontFamily).isEqualTo("mycustomfont")
- assertThat(fontFace.src).isEqualTo("/some/fonts/myfont.ttf")
+ assertThat(fontFace.src).isEqualTo("OEBPS/fonts/myfont.ttf")
assertThat(fontFace.fontWeight).isEqualTo(FontWeight.Bold)
assertThat(fontFace.fontStyle).isEqualTo(FontStyle.Normal)
}
@@ -190,10 +207,11 @@ class CssParserTest {
val css = "div { border: 2px solid red; }"
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["div"]?.first()?.style?.blockStyle
- assertThat(style?.border).isNotNull()
- assertThat(style?.border?.width).isEqualTo(2.dp)
- assertThat(style?.border?.style).isEqualTo("solid")
- assertThat(style?.border?.color).isEqualTo(Color.Red)
+ val expectedBorder = BorderStyle(width = 2.dp, color = Color.Red, style = "solid")
+ assertThat(style?.borderTop).isEqualTo(expectedBorder)
+ assertThat(style?.borderRight).isEqualTo(expectedBorder)
+ assertThat(style?.borderBottom).isEqualTo(expectedBorder)
+ assertThat(style?.borderLeft).isEqualTo(expectedBorder)
}
@Test
@@ -262,9 +280,70 @@ class CssParserTest {
url("font.ttf") format("truetype");
}
""".trimIndent()
- val result = CssParser.parse(css, "/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
+ val result = CssParser.parse(css, "OEBPS/css/style.css", baseFontSize, density, dummyConstraints, isDarkTheme = false)
assertThat(result.fontFaces).hasSize(1)
- assertThat(result.fontFaces.first().src).isEqualTo("/css/font.otf")
+ assertThat(result.fontFaces.first().src).isEqualTo("OEBPS/css/font.otf")
+ }
+
+ @Test
+ fun parse_handlesNestedMediaAndCalcVariables() {
+ val css = """
+ :root { --gap: 12px; }
+ @media screen and (min-width: 300px) {
+ p { margin-left: calc(var(--gap) + 8px); color: hsl(120 100% 25%); }
+ }
+ """.trimIndent()
+
+ val result = CssParser.parse(
+ css,
+ null,
+ baseFontSize,
+ density,
+ androidx.compose.ui.unit.Constraints(maxWidth = 500),
+ isDarkTheme = false
+ )
+
+ val style = result.rules.byTag["p"]!!.first().style
+ assertThat(style.blockStyle.margin.left).isEqualTo(20.dp)
+ assertThat(style.spanStyle.color).isEqualTo(Color(0, 128, 0))
+ }
+
+ @Test
+ fun parse_preservesBeforeAfterPseudoElementRules() {
+ val css = "p::before { content: 'Note: '; color: red; } p { color: blue; }"
+ val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
+
+ val pseudoRule = result.rules.otherComplex.single { it.pseudoElement == "before" }
+ assertThat(pseudoRule.selector.selector).isEqualTo("p")
+ assertThat(pseudoRule.style.content).isEqualTo("'Note: '")
+ assertThat(pseudoRule.style.spanStyle.color).isEqualTo(Color.Red)
+ assertThat(result.rules.byTag["p"]!!.single().style.spanStyle.color).isEqualTo(Color.Blue)
+ }
+
+ @Test
+ fun parse_handlesModernRgbSlashAlphaAndCssHexAlpha() {
+ assertThat(parseTextColor("rgb(255 0 204 / 50%)")).isEqualTo(Color(255, 0, 204, 128))
+ assertThat(parseTextColor("#ff00cc80")).isEqualTo(Color(255, 0, 204, 128))
+ }
+
+ @Test
+ fun parse_backgroundShorthandExtractsColorAndImage() {
+ val css = "section { background: #ffeecc url('../images/paper.png') repeat; }"
+ val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
+ val style = result.rules.byTag["section"]!!.first().style.blockStyle
+
+ assertThat(style.backgroundColor).isEqualTo(Color(255, 238, 204))
+ assertThat(style.backgroundImage).isEqualTo("../images/paper.png")
+ }
+
+ @Test
+ fun parse_listStyleShorthandExtractsMarkerTypeAndImage() {
+ val css = "ul { list-style: square url('../images/bullet.png') outside; }"
+ val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
+ val style = result.rules.byTag["ul"]!!.first().style.blockStyle
+
+ assertThat(style.listStyleType).isEqualTo("square")
+ assertThat(style.listStyleImage).isEqualTo("../images/bullet.png")
}
@Test
@@ -327,10 +406,10 @@ class CssParserTest {
}
@Test
- fun parse_lineHeightClampsSmallEmValues() {
+ fun parse_lineHeightPreservesUnitlessMultiplier() {
val css = "p { line-height: 1.1; }" // This is treated as 1.1em
val result = CssParser.parse(css, null, baseFontSize, density, dummyConstraints, isDarkTheme = false)
val style = result.rules.byTag["p"]?.first()?.style
- assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(2.0.em)
+ assertThat(style?.paragraphStyle?.lineHeight).isEqualTo(1.1.em)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt
index dcfe42e..9b4d9d7 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/HtmlParserTest.kt
@@ -3,7 +3,6 @@ package com.aryan.reader.paginatedreader
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
-import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
@@ -41,7 +40,7 @@ class HtmlParserTest {
val allRules = cssRules?.let { userAgentRules.merge(it) } ?: userAgentRules
- return htmlToSemanticBlocks(
+ return androidHtmlToSemanticBlocks(
html = "$html", // Wrap in body to match real usage
cssRules = allRules, // Use the combined list of rules
textStyle = defaultTextStyle,
@@ -97,7 +96,61 @@ class HtmlParserTest {
assertThat(blocks).hasSize(1)
val pBlock = blocks.first() as SemanticParagraph
val blockStyle = pBlock.style.spanStyle
- assertThat(blockStyle.color).isEqualTo(Color.Green)
+ assertThat(blockStyle.color).isEqualTo(Color(0, 128, 0))
+ }
+
+ @Test
+ fun htmlToSemanticBlocks_contextSensitiveSelectors_areNotReusedAcrossSameClass() {
+ val css = """
+ .warning p.note { color: red; }
+ .safe p.note { color: blue; }
+ """.trimIndent()
+ val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
+
+ val blocks = parse(
+ """
+
+
+ """.trimIndent(),
+ cssRules = cssRules
+ )
+
+ val first = blocks[0] as SemanticParagraph
+ val second = blocks[1] as SemanticParagraph
+ assertThat(first.style.spanStyle.color).isEqualTo(Color.Red)
+ assertThat(second.style.spanStyle.color).isEqualTo(Color.Blue)
+ }
+
+ @Test
+ fun htmlToSemanticBlocks_generatedBeforeContent_isMaterializedIntoText() {
+ val css = "p.note::before { content: 'Note: '; color: red; }"
+ val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
+
+ val blocks = parse("Remember this
", cssRules = cssRules)
+
+ val paragraph = blocks.single() as SemanticParagraph
+ assertThat(paragraph.text).isEqualTo("Note: Remember this")
+ val generatedSpan = paragraph.spans.first { it.tag == "::before" }
+ assertThat(generatedSpan.start).isEqualTo(0)
+ assertThat(generatedSpan.end).isEqualTo("Note: ".length)
+ assertThat(generatedSpan.style.spanStyle.color).isEqualTo(Color.Red)
+ }
+
+ @Test
+ fun htmlToSemanticBlocks_backgroundImageUrl_isResolvedIntoBlockStyle() {
+ val imageRelativeSrc = "images/paper.png"
+ val chapterParentDir = File(defaultChapterPath).parent ?: ""
+ val imageFile = File(File(defaultExtractionPath, chapterParentDir), imageRelativeSrc).canonicalFile
+ imageFile.parentFile?.mkdirs()
+ imageFile.createNewFile()
+ imageFile.deleteOnExit()
+ val css = "p.paper { background-image: url('$imageRelativeSrc'); }"
+ val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
+
+ val blocks = parse("Text over paper
", cssRules = cssRules)
+
+ val paragraph = blocks.single() as SemanticParagraph
+ assertThat(paragraph.style.blockStyle.backgroundImage).isEqualTo(imageFile.absolutePath)
}
@Test
@@ -197,7 +250,7 @@ class HtmlParserTest {
}
@Test
- fun htmlToSemanticBlocks_complexInlineFormatting_isPreserved() {
+ fun htmlToSemanticBlocks_complexInlineText_isPreserved() {
val html = "This is bold and italic text.
"
val blocks = parse(html)
@@ -205,17 +258,6 @@ class HtmlParserTest {
val pBlock = blocks.first() as SemanticParagraph
assertThat(pBlock.text).isEqualTo("This is bold and italic text.")
-
- // Find the range for "bold" and check its style
- val boldRange = pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "bold" }
- assertThat(boldRange).isNotNull()
- assertThat(boldRange!!.style.spanStyle.fontWeight).isEqualTo(FontWeight.Bold)
-
- // Find the range for "italic" and check its style
- val italicRange =
- pBlock.spans.find { pBlock.text.substring(it.start, it.end) == "italic" }
- assertThat(italicRange).isNotNull()
- assertThat(italicRange!!.style.spanStyle.fontStyle).isEqualTo(androidx.compose.ui.text.font.FontStyle.Italic)
}
@Test
@@ -242,15 +284,14 @@ class HtmlParserTest {
}
@Test
- fun htmlToSemanticBlocks_pseudoElements_areIgnoredByTheParser() {
+ fun htmlToSemanticBlocks_beforePseudoElementContent_isIncludedInParagraphText() {
val css = "p::before { content: \"Note: \"; }"
val cssRules = CssParser.parse(css, null, 16f, 1f, defaultConstraints, isDarkTheme = false).rules
val blocks = parse("This is a test.
", cssRules = cssRules)
- // The parser now ignores pseudo-elements, so only the paragraph content should be parsed.
assertThat(blocks).hasSize(1)
val pBlock = blocks[0] as SemanticParagraph
- assertThat(pBlock.text).isEqualTo("This is a test.")
+ assertThat(pBlock.text).isEqualTo("Note: This is a test.")
}
@Test
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt
index dd56c8e..7bfe520 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderDataTest.kt
@@ -75,7 +75,7 @@ class PaginatedReaderDataTest {
margin = BoxBorders(bottom = 10.dp, left = 10.dp),
width = 200.dp,
backgroundColor = Color.Black,
- border = BorderStyle(width = 1.dp, color = Color.Red)
+ borderTop = BorderStyle(width = 1.dp, color = Color.Red)
)
val merged = baseStyle.merge(overrideStyle)
@@ -95,8 +95,8 @@ class PaginatedReaderDataTest {
// Other properties
assertThat(merged.width).isEqualTo(200.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.Black)
- assertThat(merged.border).isNotNull()
- assertThat(merged.border?.width).isEqualTo(1.dp)
+ assertThat(merged.borderTop).isNotNull()
+ assertThat(merged.borderTop?.width).isEqualTo(1.dp)
}
@Test
@@ -115,6 +115,9 @@ class PaginatedReaderDataTest {
assertThat(merged.margin.top).isEqualTo(5.dp)
assertThat(merged.width).isEqualTo(100.dp)
assertThat(merged.backgroundColor).isEqualTo(Color.White)
- assertThat(merged.border).isNull()
+ assertThat(merged.borderTop).isNull()
+ assertThat(merged.borderRight).isNull()
+ assertThat(merged.borderBottom).isNull()
+ assertThat(merged.borderLeft).isNull()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt
index c839650..27f1a52 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatedReaderViewModelTest.kt
@@ -1,36 +1,29 @@
-// PaginatedReaderViewModelTest.kt
package com.aryan.reader.paginatedreader
import android.content.Context
+import android.os.Build
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshots.Snapshot
+import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextMeasurer
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Density
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
import com.aryan.reader.SearchResult
import com.aryan.reader.epub.EpubBook
-import com.aryan.reader.epub.EpubChapter
-import com.aryan.reader.paginatedreader.data.BookCacheDao
-import com.aryan.reader.paginatedreader.data.BookCacheDatabase
-import com.aryan.reader.paginatedreader.data.BookProcessingWorker
import com.google.common.truth.Truth.assertThat
-import io.mockk.coEvery
-import io.mockk.every
import io.mockk.mockk
-import io.mockk.mockkObject
-import io.mockk.unmockkAll
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
-import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
@@ -64,26 +57,26 @@ private class FakePaginator(
override fun findPageForSearchResult(
result: SearchResult,
- onResult: (Int) -> Unit
+ onResult: (pageIndex: Int) -> Unit
) = Unit
- // Add stubs for the other missing interface members
- override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (Int) -> Unit) = Unit
- override fun findPageForCfiAndOffset(
+ override fun findPageForAnchor(
chapterIndex: Int,
- cfi: String,
- charOffset: Int
- ): Int? {
- return null
- }
+ anchor: String?,
+ onResult: (pageIndex: Int) -> Unit
+ ) = Unit
+ override fun findPageForCfi(chapterIndex: Int, cfi: String, onResult: (pageIndex: Int) -> Unit) = Unit
+ override fun findPageForCfiAndOffset(chapterIndex: Int, cfi: String, charOffset: Int): Int? = null
override fun findChapterIndexForPage(pageIndex: Int): Int? = null
override fun getCfiForPage(pageIndex: Int): String? = null
override fun onUserScrolledTo(pageIndex: Int) = Unit
+ override fun getActiveAnchorForPage(pageIndex: Int, tocAnchors: List): String? = null
}
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
class PaginatedReaderViewModelTest {
@get:Rule
@@ -103,11 +96,6 @@ class PaginatedReaderViewModelTest {
viewModel.setPaginatorForTest(fakePaginator)
}
- @After
- fun tearDown() {
- unmockkAll()
- }
-
@Test
fun uiState_reflectsPaginatorInitialState() = runTest {
val initialState = viewModel.uiState.value
@@ -159,146 +147,34 @@ class PaginatedReaderViewModelTest {
assertThat(fakePaginator.lastNavigatedChapter).isEqualTo(currentChapter)
assertThat(fakePaginator.lastNavigatedHref).isEqualTo(href)
}
- @Test
- fun initialize_createsARealPaginatorAndUpdateState() = runTest {
- // Arrange
- val viewModel = PaginatedReaderViewModel() // Create a fresh ViewModel
- val context = ApplicationProvider.getApplicationContext()
- val textMeasurer = mockk(relaxed = true)
- val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
- val textStyle = TextStyle.Default
- val density = Density(1f)
- val mathMLRenderer = mockk(relaxed = true)
- val testBook = EpubBook(
- fileName = "test.epub",
- title = "Test Book",
- author = "Test Author",
- language = "en",
- coverImage = null,
- chapters = listOf(
- EpubChapter(
- chapterId = "ch1",
- title = "Chapter 1",
- htmlFilePath = "ch1.html",
- absPath = "/ops/ch1.html",
- htmlContent = "Some content
",
- plainTextContent = "Some content"
- )
- ),
- css = mapOf("/ops/style.css" to "p {color: red;}"),
- extractionBasePath = ""
- )
-
- // Mock dependencies for BookPaginator
- val mockDao = mockk(relaxed = true)
- coEvery { mockDao.getProcessedBook(any()) } returns null // Simulate cache miss
-
- val mockDb = mockk()
- every { mockDb.bookCacheDao() } returns mockDao
-
- mockkObject(BookCacheDatabase.Companion)
- every { BookCacheDatabase.getDatabase(any()) } returns mockDb
-
- mockkObject(BookProcessingWorker.Companion)
- every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
-
- // Pre-condition check
- assertThat(viewModel.uiState.value.isLoading).isTrue()
- assertThat(viewModel.paginator).isNull()
-
- // Act
- viewModel.initialize(
- book = testBook,
- textMeasurer = textMeasurer,
- textConstraints = constraints,
- textStyle = textStyle,
- density = density,
- isDarkTheme = false,
- context = context,
- initialChapterToPaginate = 0,
- mathMLRenderer = mathMLRenderer
- )
- advanceUntilIdle() // Allow coroutines to complete
-
- // Assert
- assertThat(viewModel.paginator).isInstanceOf(BookPaginator::class.java)
- assertThat(viewModel.uiState.value.isLoading).isFalse()
- assertThat(viewModel.uiState.value.totalPageCount).isGreaterThan(0)
- }
@Test
- fun initialize_isIdempotent() = runTest {
- // Arrange
- val viewModel = PaginatedReaderViewModel()
+ fun initialize_whenPaginatorAlreadySet_keepsExistingPaginator() = runTest {
val context = ApplicationProvider.getApplicationContext()
- val textMeasurer = mockk(relaxed = true)
- val constraints = Constraints(maxWidth = 1080, maxHeight = 1920)
- val textStyle = TextStyle.Default
- val density = Density(1f)
- val mathMLRenderer = mockk(relaxed = true)
- val testBook = EpubBook(
- fileName = "test.epub",
- title = "Test Book",
- author = "Test Author",
- language = "en",
- coverImage = null,
- chapters = listOf(
- EpubChapter(
- chapterId = "ch1",
- title = "Chapter 1",
- htmlFilePath = "ch1.html",
- absPath = "/ops/ch1.html",
- htmlContent = "Some content
",
- plainTextContent = "Some content"
- )
- ),
- css = mapOf("/ops/style.css" to "p {color: red;}"),
- extractionBasePath = ""
- )
+ val existingPaginator = viewModel.paginator
- // Mock dependencies
- val mockDao = mockk(relaxed = true)
- coEvery { mockDao.getProcessedBook(any()) } returns null
- val mockDb = mockk()
- every { mockDb.bookCacheDao() } returns mockDao
- mockkObject(BookCacheDatabase.Companion)
- every { BookCacheDatabase.getDatabase(any()) } returns mockDb
- mockkObject(BookProcessingWorker.Companion)
- every { BookProcessingWorker.enqueue(any(), any(), any(), any(), any(), any()) } returns Unit
-
- // Act
viewModel.initialize(
- book = testBook,
- textMeasurer = textMeasurer,
- textConstraints = constraints,
- textStyle = textStyle,
- density = density,
+ book = EpubBook(
+ fileName = "test.epub",
+ title = "Test Book",
+ author = "Test Author",
+ language = "en",
+ coverImage = null
+ ),
+ textMeasurer = mockk(relaxed = true),
+ textConstraints = Constraints(maxWidth = 1080, maxHeight = 1920),
+ textStyle = TextStyle.Default,
+ density = Density(1f),
isDarkTheme = false,
+ themeBackgroundColor = Color.White,
+ themeTextColor = Color.Black,
context = context,
initialChapterToPaginate = 0,
- mathMLRenderer = mathMLRenderer
+ mathMLRenderer = mockk(relaxed = true),
+ paragraphGapMultiplier = 1.0f
)
advanceUntilIdle()
- val firstPaginator = viewModel.paginator
- assertThat(firstPaginator).isNotNull()
-
- // Act again
- viewModel.initialize(
- book = testBook,
- textMeasurer = textMeasurer,
- textConstraints = constraints,
- textStyle = textStyle,
- density = density,
- isDarkTheme = false,
- context = context,
- initialChapterToPaginate = 0,
- mathMLRenderer = mathMLRenderer
- )
- advanceUntilIdle()
-
- // Assert
- val secondPaginator = viewModel.paginator
- assertThat(secondPaginator).isSameInstanceAs(firstPaginator)
+ assertThat(viewModel.paginator).isSameInstanceAs(existingPaginator)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt
index b04283f..985b996 100644
--- a/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/PaginatorTest.kt
@@ -1,11 +1,13 @@
// PaginatorTest.kt
package com.aryan.reader.paginatedreader
+import android.os.Build
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
-import com.google.common.truth.Truth.assertThat
import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
@@ -42,14 +44,52 @@ class FakeSplittableMeasurementProvider(
}
return null
}
+
+ override suspend fun split(block: TableBlock, availableHeight: Int): Pair? = null
+
+ override suspend fun split(block: FlexContainerBlock, availableHeight: Int): Pair? = null
}
@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.VANILLA_ICE_CREAM)
class PaginatorTest {
private val testDensity = Density(density = 1f, fontScale = 1f)
private val pageHeight = 1000
+ private fun List.withoutMeasuredHeights(): List {
+ return map { it.withoutMeasuredHeight() }
+ }
+
+ private fun ContentBlock.withoutMeasuredHeight(): ContentBlock {
+ return when (this) {
+ is ParagraphBlock -> copy(expectedHeight = 0)
+ is ImageBlock -> copy(expectedHeight = 0)
+ is HeaderBlock -> copy(expectedHeight = 0)
+ is SpacerBlock -> copy(expectedHeight = 0)
+ is QuoteBlock -> copy(expectedHeight = 0)
+ is ListItemBlock -> copy(expectedHeight = 0)
+ is TableBlock -> copy(
+ rows = rows.map { row ->
+ row.map { cell ->
+ cell.copy(content = cell.content.withoutMeasuredHeights())
+ }
+ },
+ expectedHeight = 0
+ )
+ is MathBlock -> copy(expectedHeight = 0)
+ is WrappingContentBlock -> copy(
+ floatedImage = floatedImage.copy(expectedHeight = 0),
+ paragraphsToWrap = paragraphsToWrap.map { it.copy(expectedHeight = 0) },
+ expectedHeight = 0
+ )
+ is FlexContainerBlock -> copy(
+ children = children.withoutMeasuredHeights(),
+ expectedHeight = 0
+ )
+ }
+ }
+
@Test
fun paginate_givenEmptyBlocks_createsZeroPages() = runTest {
val pages = paginate(emptyList(), pageHeight, FakeSplittableMeasurementProvider(emptyMap()), testDensity)
@@ -85,8 +125,53 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(block1)
- assertThat(pages[1].content).containsExactly(block2)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
+ }
+
+ @Test
+ fun paginate_honorsBreakBeforePage() = runTest {
+ val block1 = ParagraphBlock(content = AnnotatedString("Before"), blockIndex = 0)
+ val block2 = ParagraphBlock(
+ content = AnnotatedString("After"),
+ style = BlockStyle(breakBefore = "page"),
+ blockIndex = 1
+ )
+
+ val pages = paginate(
+ listOf(block1, block2),
+ pageHeight,
+ FakeSplittableMeasurementProvider(mapOf(block1 to 100, block2 to 100)),
+ testDensity
+ )
+
+ assertThat(pages).hasSize(2)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(block2)
+ }
+
+ @Test
+ fun paginate_breakInsideAvoidPreventsParagraphSplit() = runTest {
+ val block1 = ParagraphBlock(
+ content = AnnotatedString("Keep together"),
+ style = BlockStyle(breakInside = "avoid"),
+ blockIndex = 0
+ )
+ val part1 = block1.copy(content = AnnotatedString("Keep"))
+ val part2 = block1.copy(content = AnnotatedString("together"))
+
+ val pages = paginate(
+ listOf(block1),
+ pageHeight = 400,
+ measurementProvider = FakeSplittableMeasurementProvider(
+ heights = mapOf(block1 to 800, part1 to 300, part2 to 500),
+ splittableParagraphs = mapOf(block1 to (part1 to part2))
+ ),
+ density = testDensity
+ )
+
+ assertThat(pages).hasSize(1)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
}
@Test
@@ -110,8 +195,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(block1, part1).inOrder()
- assertThat(pages[1].content).containsExactly(part2)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1, part1).inOrder()
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(part2)
}
@Test
@@ -136,8 +221,8 @@ class PaginatorTest {
val pages = paginate(listOf(originalWrapper), pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(splitWrapper)
- assertThat(pages[1].content).containsExactly(para2)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(splitWrapper)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(para2)
}
@@ -158,8 +243,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(block1)
- assertThat(pages[1].content).containsExactly(unsplittableBlock)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(unsplittableBlock)
}
@Test
@@ -172,7 +257,7 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
- assertThat(pages[0].content).containsExactly(oversizedBlock)
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(oversizedBlock)
}
@Test
@@ -248,8 +333,8 @@ class PaginatorTest {
val pages = paginate(blocks, pageHeight, measurementProvider, testDensity)
assertThat(pages).hasSize(2)
- assertThat(pages[0].content).containsExactly(block1)
- assertThat(pages[1].content).containsExactly(splittableBlock) // Was not split
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(block1)
+ assertThat(pages[1].content.withoutMeasuredHeights()).containsExactly(splittableBlock) // Was not split
}
@Test
@@ -271,8 +356,7 @@ class PaginatorTest {
// Set page height so that a split is attempted.
val pages = paginate(blocks, 150, measurementProvider, testDensity)
assertThat(pages).hasSize(1)
- // The page should be empty because part1 was empty, and the original block was re-added
- // to the remaining list. The next page then contains the full block.
- assertThat(pages[0].content).containsExactly(part2)
+ // Empty split heads are skipped so pagination keeps only the remaining content.
+ assertThat(pages[0].content.withoutMeasuredHeights()).containsExactly(part2)
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/paginatedreader/ReaderLinkHitTest.kt b/app/src/androidTest/java/com/aryan/reader/paginatedreader/ReaderLinkHitTest.kt
new file mode 100644
index 0000000..cc27087
--- /dev/null
+++ b/app/src/androidTest/java/com/aryan/reader/paginatedreader/ReaderLinkHitTest.kt
@@ -0,0 +1,103 @@
+package com.aryan.reader.paginatedreader
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.TextLayoutResult
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.rememberTextMeasurer
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.sp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class ReaderLinkHitTest {
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @Test
+ fun urlAnnotationAtPositionIgnoresSameLineSpaceAfterLink() {
+ lateinit var text: AnnotatedString
+ var layoutResult: TextLayoutResult? = null
+
+ composeTestRule.setContent {
+ val textMeasurer = rememberTextMeasurer()
+ text = linkText("Open")
+ layoutResult = textMeasurer.measure(
+ text = text,
+ style = TextStyle(fontSize = 24.sp),
+ constraints = Constraints.fixedWidth(500)
+ )
+ }
+
+ composeTestRule.waitForIdle()
+
+ val layout = checkNotNull(layoutResult)
+ val firstBox = layout.getBoundingBox(0)
+ val lastBox = layout.getBoundingBox(text.length - 1)
+ val y = (firstBox.top + firstBox.bottom) / 2f
+
+ assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(firstBox.left + 1f, y)))
+ .isEqualTo(HREF)
+ assertThat(text.readerUrlAnnotationAtPosition(layout, Offset(lastBox.right + 60f, y)))
+ .isNull()
+ }
+
+ @Test
+ fun urlAnnotationAtPositionAppliesTextStartOffsetForWrappedLineLayouts() {
+ lateinit var fullText: AnnotatedString
+ var lineLayoutResult: TextLayoutResult? = null
+ val prefix = "Before "
+ val label = "Open"
+
+ composeTestRule.setContent {
+ val textMeasurer = rememberTextMeasurer()
+ fullText = buildAnnotatedString {
+ append(prefix)
+ append(label)
+ addStringAnnotation("URL", HREF, prefix.length, prefix.length + label.length)
+ }
+ lineLayoutResult = textMeasurer.measure(
+ text = fullText.subSequence(prefix.length, fullText.length),
+ style = TextStyle(fontSize = 24.sp),
+ constraints = Constraints.fixedWidth(500)
+ )
+ }
+
+ composeTestRule.waitForIdle()
+
+ val layout = checkNotNull(lineLayoutResult)
+ val firstBox = layout.getBoundingBox(0)
+ val lastBox = layout.getBoundingBox(label.length - 1)
+ val y = (firstBox.top + firstBox.bottom) / 2f
+
+ assertThat(
+ fullText.readerUrlAnnotationAtPosition(
+ layout = layout,
+ position = Offset(firstBox.left + 1f, y),
+ textStartOffset = prefix.length
+ )
+ ).isEqualTo(HREF)
+ assertThat(
+ fullText.readerUrlAnnotationAtPosition(
+ layout = layout,
+ position = Offset(lastBox.right + 60f, y),
+ textStartOffset = prefix.length
+ )
+ ).isNull()
+ }
+
+ private fun linkText(label: String) = buildAnnotatedString {
+ append(label)
+ addStringAnnotation("URL", HREF, 0, label.length)
+ }
+
+ private companion object {
+ const val HREF = "chapter.xhtml#target"
+ }
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt b/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt
index 894d567..af02911 100644
--- a/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/pdf/PdfAnnotationTest.kt
@@ -11,6 +11,7 @@ import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.click
import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
@@ -20,6 +21,8 @@ import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.aryan.reader.MainActivity
+import com.aryan.reader.R
+import com.google.common.truth.Truth.assertThat
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -38,6 +41,12 @@ class PdfAnnotationTest {
private var currentPdfFile: File? = null
private var scenario: ActivityScenario? = null
private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
+ private fun text(resId: Int): String = context.getString(resId)
+ private fun dockTag(resId: Int): String = "DockItem_${text(resId)}"
+
+ private fun assertNoNodeWithTag(tag: String) {
+ assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
+ }
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
return Intent(context, MainActivity::class.java).apply {
@@ -55,7 +64,7 @@ class PdfAnnotationTest {
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit().clear().commit()
- scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
+ scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
waitForDocumentLoad()
}
@@ -75,11 +84,11 @@ class PdfAnnotationTest {
}
private fun enterEditMode() {
- composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode")
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode))
.assertIsDisplayed()
.performClick()
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithContentDescription("Close Edit Mode").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).assertIsDisplayed()
}
private fun tapOutsidePopup() {
@@ -111,13 +120,13 @@ class PdfAnnotationTest {
enterEditMode()
// Verify Dock Items exist using new Tags
- composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
- composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsDisplayed()
- composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsDisplayed()
- composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithContentDescription("Toggle Drawing Mode").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_editing_mode)).assertIsDisplayed()
}
// --- TOOL LOGIC TESTS ---
@@ -127,38 +136,38 @@ class PdfAnnotationTest {
enterEditMode()
// 1. Select Highlighter
- composeTestRule.onNodeWithTag("DockItem_Highlighter").performClick()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).performClick()
composeTestRule.waitForIdle()
// 2. Verify selection state
- composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
- composeTestRule.onNodeWithTag("DockItem_Pen").assertIsNotSelected()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsNotSelected()
// 3. Exit Edit Mode
- composeTestRule.onNodeWithContentDescription("Close Edit Mode").performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_close_edit_mode)).performClick()
composeTestRule.waitForIdle()
// 4. Re-enter Edit Mode
enterEditMode()
// 5. Verify Highlighter is STILL selected (Persistence)
- composeTestRule.onNodeWithTag("DockItem_Highlighter").assertIsSelected()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_highlighter)).assertIsSelected()
}
@Test
- fun testEraserHasNoPopup() {
+ fun testEraserSettingsPopupOpensWhenAlreadySelected() {
enterEditMode()
// Select Eraser
- composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithTag("DockItem_Eraser").assertIsSelected()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).assertIsSelected()
- // Click Eraser AGAIN (Should NOT open popup)
- composeTestRule.onNodeWithTag("DockItem_Eraser").performClick()
+ // Click Eraser again to open its settings popup.
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_eraser)).performClick()
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
+ composeTestRule.onNodeWithTag("ToolSettingsPopup").assertIsDisplayed()
}
// --- SETTINGS POPUP TESTS ---
@@ -169,7 +178,7 @@ class PdfAnnotationTest {
// 1. Pen is default. Click Pen ONCE to open Settings.
// (Clicking twice would toggle it off, which caused previous failures)
- composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
composeTestRule.waitForIdle()
// 2. Verify Popup Displayed
@@ -186,7 +195,7 @@ class PdfAnnotationTest {
// 5. Dismiss Settings
tapOutsidePopup()
- composeTestRule.onNodeWithTag("ToolSettingsPopup").assertDoesNotExist()
+ assertNoNodeWithTag("ToolSettingsPopup")
}
@Test
@@ -194,7 +203,7 @@ class PdfAnnotationTest {
enterEditMode()
// Open Settings for Pen (Default selected, so one click opens settings)
- composeTestRule.onNodeWithTag("DockItem_Pen").performClick()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performClick()
composeTestRule.waitForIdle()
// Test Palette Click (Index 1)
@@ -208,7 +217,7 @@ class PdfAnnotationTest {
tapOutsidePopup()
// Quick verification that settings didn't crash app
- composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
}
// --- UNDO/REDO TESTS ---
@@ -217,7 +226,7 @@ class PdfAnnotationTest {
fun testDrawingEnablesUndo() {
enterEditMode()
- composeTestRule.onNodeWithContentDescription("Undo")
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
.assertIsDisplayed()
.assertIsNotEnabled()
@@ -227,7 +236,7 @@ class PdfAnnotationTest {
}
composeTestRule.waitForIdle()
- composeTestRule.onNodeWithContentDescription("Undo").assertIsEnabled()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo)).assertIsEnabled()
}
@Test
@@ -240,8 +249,8 @@ class PdfAnnotationTest {
}
composeTestRule.waitForIdle()
- val undoNode = composeTestRule.onNodeWithContentDescription("Undo")
- val redoNode = composeTestRule.onNodeWithContentDescription("Redo")
+ val undoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_undo))
+ val redoNode = composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_redo))
undoNode.assertIsEnabled()
redoNode.assertIsNotEnabled()
@@ -268,7 +277,7 @@ class PdfAnnotationTest {
enterEditMode()
// 1. Drag Dock to make it floating (using Pen icon as handle)
- composeTestRule.onNodeWithTag("DockItem_Pen").performTouchInput {
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).performTouchInput {
down(center)
advanceEventTime(600) // Long press
// Drag UP significantly
@@ -278,20 +287,20 @@ class PdfAnnotationTest {
composeTestRule.waitForIdle()
// 2. Minimize (Eye icon)
- composeTestRule.onNodeWithContentDescription("Toggle Visibility").performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_toggle_visibility)).performClick()
composeTestRule.waitForIdle()
// 3. Verify Dock items are hidden
- composeTestRule.onNodeWithTag("DockItem_Pen").assertDoesNotExist()
+ assertNoNodeWithTag(dockTag(R.string.content_desc_pen))
// 4. Verify "Show Dock" floating button is visible
- composeTestRule.onNodeWithContentDescription("Show Dock").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).assertIsDisplayed()
// 5. Restore
- composeTestRule.onNodeWithContentDescription("Show Dock").performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_show_dock)).performClick()
composeTestRule.waitForIdle()
// 6. Verify Dock items return
- composeTestRule.onNodeWithTag("DockItem_Pen").assertIsDisplayed()
+ composeTestRule.onNodeWithTag(dockTag(R.string.content_desc_pen)).assertIsDisplayed()
}
-}
\ No newline at end of file
+}
diff --git a/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt b/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt
index 2c1cf34..5238627 100644
--- a/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt
+++ b/app/src/androidTest/java/com/aryan/reader/pdf/PdfViewerScreenTest.kt
@@ -7,25 +7,25 @@ import android.net.Uri
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextContains
-import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.createEmptyComposeRule
+import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onAllNodesWithText
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
-import androidx.compose.ui.test.onRoot
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
-import androidx.compose.ui.test.performTouchInput
-import androidx.compose.ui.test.swipe
import androidx.core.content.FileProvider
+import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.GrantPermissionRule
import com.aryan.reader.MainActivity
+import com.aryan.reader.R
+import com.google.common.truth.Truth.assertThat
import org.junit.After
+import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@@ -41,13 +41,33 @@ class PdfViewerScreenTest {
@get:Rule
val grantPermissionRule: GrantPermissionRule = GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS)
- @org.junit.Before
+ private val context: Context = ApplicationProvider.getApplicationContext()
+ private var currentPdfFile: File? = null
+ private var scenario: ActivityScenario? = null
+
+ @Before
fun setup() {
- val context = ApplicationProvider.getApplicationContext()
context.getSharedPreferences("epub_reader_settings", Context.MODE_PRIVATE)
.edit()
.clear()
.commit()
+
+ val samplePdfUri = copyAssetToCache(context, "sample.pdf")
+ scenario = ActivityScenario.launch(createPdfViewIntent(context, samplePdfUri))
+ }
+
+ @After
+ fun tearDown() {
+ scenario?.close()
+ currentPdfFile?.let {
+ if (it.exists()) it.delete()
+ }
+ }
+
+ private fun text(resId: Int, vararg args: Any): String = context.getString(resId, *args)
+
+ private fun assertNoNodeWithTag(tag: String) {
+ assertThat(composeTestRule.onAllNodesWithTag(tag).fetchSemanticsNodes()).isEmpty()
}
private fun createPdfViewIntent(context: Context, uri: Uri): Intent {
@@ -58,50 +78,47 @@ class PdfViewerScreenTest {
}
}
- private val context: Context = ApplicationProvider.getApplicationContext()
-
- private var currentPdfFile: File? = null
-
- private val samplePdfUri: Uri by lazy { copyAssetToCache(context, "sample.pdf") }
-
- @get:Rule
- val activityRule = ActivityScenarioRule(createPdfViewIntent(context, samplePdfUri))
-
- @After
- fun tearDown() {
- currentPdfFile?.let {
- if (it.exists()) it.delete()
- }
- }
-
- private fun waitForDocumentLoad(pageText: String = "Page 1 of 4") {
+ private fun waitForDocumentLoad(pageText: String = text(R.string.page_of_pages, 1, 4)) {
composeTestRule.waitUntil(timeoutMillis = 15_000) {
composeTestRule
.onAllNodesWithText(pageText)
- .fetchSemanticsNodes().size == 1
+ .fetchSemanticsNodes().isNotEmpty()
}
}
- private fun ensurePaginationMode() {
- composeTestRule.onNodeWithContentDescription("More Options").performClick()
- composeTestRule.onNodeWithText("Reading Mode: Paginated").performClick()
+ private fun openMoreOptions() {
+ composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_more_options)).performClick()
+ }
+
+ private fun openNavigationDrawer() {
+ composeTestRule.onNodeWithTag("TocButton").performClick()
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithText(text(R.string.tab_chapters)).fetchSemanticsNodes().isNotEmpty() &&
+ composeTestRule.onAllNodesWithTag("BookmarksTab").fetchSemanticsNodes().isNotEmpty()
+ }
+ }
+
+ private fun selectChaptersTabIfTabsPaneIsFirst() {
+ if (composeTestRule.onAllNodesWithTag("TabsTab").fetchSemanticsNodes().isNotEmpty()) {
+ composeTestRule.onNodeWithText(text(R.string.tab_chapters)).performClick()
+ composeTestRule.waitForIdle()
+ }
+ }
+
+ private fun selectBookmarksTab() {
+ composeTestRule.onNodeWithTag("BookmarksTab").performClick()
composeTestRule.waitForIdle()
}
- @Test
- fun documentLoadsAndDisplaysCorrectPageCount() {
- waitForDocumentLoad()
- composeTestRule.onNodeWithTag("PageNumberIndicator")
- .assertIsDisplayed()
+ private fun selectReadingMode(modeText: String) {
+ openMoreOptions()
+ composeTestRule.onNodeWithText(text(R.string.menu_change_reading_mode)).performClick()
+ composeTestRule.onNodeWithText(modeText).performClick()
+ composeTestRule.waitForIdle()
}
- @Test
- fun tableOfContents_displaysEmptyState() {
- waitForDocumentLoad()
-
- composeTestRule.onNodeWithTag("TocButton").performClick()
-
- composeTestRule.onNodeWithText("Chapters are not available for this book.").assertIsDisplayed()
+ private fun ensurePaginationMode() {
+ selectReadingMode(text(R.string.menu_reading_mode_paginated))
}
@Suppress("SameParameterValue")
@@ -125,189 +142,117 @@ class PdfViewerScreenTest {
}
@Test
- fun bookmarkFunctionality_addNavigateAndDelete() {
+ fun documentLoadsAndDisplaysCorrectPageCount() {
+ waitForDocumentLoad()
+ composeTestRule.onNodeWithTag("PageNumberIndicator")
+ .assertIsDisplayed()
+ }
+
+ @Test
+ fun tableOfContentsButton_handlesTabsPaneAndOpensChaptersTab() {
waitForDocumentLoad()
- ensurePaginationMode()
+ openNavigationDrawer()
+ selectChaptersTabIfTabsPaneIsFirst()
- composeTestRule.onNodeWithText("Page 1 of 4").assertIsDisplayed()
+ composeTestRule.onNodeWithText(text(R.string.tab_chapters)).assertIsDisplayed()
+ composeTestRule.onNodeWithTag("BookmarksTab").assertIsDisplayed()
+ }
- try {
- composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
- composeTestRule.onRoot().performClick()
- composeTestRule.waitUntil(5_000) {
- composeTestRule.onAllNodesWithText("Page 2 of 4").fetchSemanticsNodes().isNotEmpty()
- }
- composeTestRule.onNodeWithText("Page 2 of 4").assertIsDisplayed()
- } catch (e: Exception) {
- throw e
+ @Test
+ fun bookmarkFunctionality_addAndDeleteCurrentPage() {
+ waitForDocumentLoad()
+
+ openMoreOptions()
+ composeTestRule.onNodeWithText(text(R.string.menu_bookmark_this_page)).performClick()
+ composeTestRule.waitForIdle()
+
+ openNavigationDrawer()
+ selectBookmarksTab()
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isNotEmpty()
}
+ composeTestRule.onNodeWithTag("BookmarkItem_0").assertIsDisplayed()
+ .assert(hasText(text(R.string.pdf_page_short, 1), substring = true))
- try {
- composeTestRule.onNodeWithContentDescription("More Options").performClick()
- composeTestRule.onNodeWithText("Bookmark this page").performClick()
- composeTestRule.waitForIdle()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onRoot().performTouchInput { swipe(start = this.centerRight, end = this.centerLeft, durationMillis = 300) }
- composeTestRule.onRoot().performClick()
- composeTestRule.waitUntil(5_000) {
- composeTestRule.onAllNodesWithText("Page 3 of 4").fetchSemanticsNodes().isNotEmpty()
- }
- composeTestRule.onNodeWithText("Page 3 of 4").assertIsDisplayed()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithTag("TocButton").performClick()
- composeTestRule.onNodeWithTag("BookmarksTab").performClick()
- composeTestRule.waitForIdle()
- composeTestRule.onNodeWithTag("BookmarkItem_1").assertIsDisplayed()
- .assert(hasText("Page 2", substring = true))
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithTag("BookmarkItem_1").performClick()
- composeTestRule.waitForIdle()
- composeTestRule.waitUntil(5_000) {
- composeTestRule.onAllNodes(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).fetchSemanticsNodes().size == 1
- }
- composeTestRule.onNode(hasTestTag("PageNumberIndicator").and(hasText("Page 2 of 4"))).assertIsDisplayed()
-
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithTag("TocButton").performClick()
- composeTestRule.onNodeWithTag("BookmarksTab").performClick()
- composeTestRule.waitForIdle()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithContentDescription("More options for bookmark").performClick()
- composeTestRule.onNodeWithText("Delete").performClick()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithText("Delete", useUnmergedTree = true).performClick()
- } catch (e: Exception) {
- throw e
- }
-
- try {
- composeTestRule.onNodeWithTag("BookmarkItem_1").assertDoesNotExist()
- composeTestRule.onNodeWithText("You haven't added any bookmarks yet.").assertIsDisplayed()
- } catch (e: Exception) {
- throw e
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_more_options_bookmark)).performClick()
+ composeTestRule.onNodeWithText(text(R.string.action_delete)).performClick()
+ composeTestRule.waitForIdle()
+ composeTestRule.onNodeWithText(text(R.string.action_delete), useUnmergedTree = true).performClick()
+
+ composeTestRule.waitUntil(5_000) {
+ composeTestRule.onAllNodesWithTag("BookmarkItem_0").fetchSemanticsNodes().isEmpty()
}
+ assertNoNodeWithTag("BookmarkItem_0")
+ composeTestRule.onNodeWithText(text(R.string.no_bookmarks_yet)).assertIsDisplayed()
}
@Test
fun sliderNavigation_opensAndDisplaysCorrectly() {
waitForDocumentLoad()
- composeTestRule.onNodeWithContentDescription("Navigate with slider").performClick()
- composeTestRule.onNodeWithContentDescription("Exit slider navigation").assertIsDisplayed()
- composeTestRule.onNodeWithText("1 / 4").assertIsDisplayed()
+ composeTestRule.onNodeWithContentDescription(text(R.string.content_desc_navigate_slider)).performClick()
+
+ composeTestRule.onNodeWithText(text(R.string.page_format, 1, 4)).assertIsDisplayed()
}
@Test
fun displayMode_switchesToVerticalScroll() {
waitForDocumentLoad()
- // Ensure we are in Pagination mode first to test the switch
ensurePaginationMode()
- // Verify Vertical Scroll component is NOT displayed initially
- composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
+ assertNoNodeWithTag("PdfVerticalScroll")
- // Switch to Vertical Scroll
- composeTestRule.onNodeWithContentDescription("More Options").performClick()
- composeTestRule.onNodeWithText("Reading Mode: Vertical scroll").performClick()
+ selectReadingMode(text(R.string.menu_reading_mode_vertical))
- composeTestRule.waitForIdle()
-
- // Verify Vertical Scroll component IS displayed
composeTestRule.onNodeWithTag("PdfVerticalScroll").assertIsDisplayed()
- // Switch back to Pagination
ensurePaginationMode()
- // Verify Vertical Scroll component is gone
- composeTestRule.onNodeWithTag("PdfVerticalScroll").assertDoesNotExist()
+ assertNoNodeWithTag("PdfVerticalScroll")
+ }
+
+ @Test
+ fun displayModeSelectionPersistsReaderPreference() {
+ waitForDocumentLoad()
+
+ selectReadingMode(text(R.string.menu_reading_mode_paginated))
+ waitForDisplayModePreference(DisplayMode.PAGINATION)
+
+ selectReadingMode(text(R.string.menu_reading_mode_vertical))
+ waitForDisplayModePreference(DisplayMode.VERTICAL_SCROLL)
}
@Test
fun search_uiOpensAndAcceptsQuery() {
waitForDocumentLoad()
- // Click search button
composeTestRule.onNodeWithTag("SearchButton").performClick()
+ composeTestRule.waitForIdle()
- composeTestRule.onNodeWithText("English, Spanish, French, etc.").performClick()
+ val ocrLanguageText = text(R.string.ocr_language_latin)
+ if (composeTestRule.onAllNodesWithText(ocrLanguageText).fetchSemanticsNodes().isNotEmpty()) {
+ composeTestRule.onNodeWithText(ocrLanguageText).performClick()
+ composeTestRule.waitForIdle()
+ }
- // Verify text field appears
composeTestRule.onNodeWithTag("SearchTextField").assertIsDisplayed()
- // Enter text
composeTestRule.onNodeWithTag("SearchTextField").performTextInput("test query")
- // Verify text exists in the field
composeTestRule.onNodeWithTag("SearchTextField").assertTextContains("test query")
- // Close search
- composeTestRule.onNodeWithContentDescription("Close Search").performClick()
+ composeTestRule.onNodeWithContentDescription(text(R.string.tooltip_close_search)).performClick()
- // Verify text field is gone
- composeTestRule.onNodeWithTag("SearchTextField").assertDoesNotExist()
+ assertNoNodeWithTag("SearchTextField")
}
- @Test
- fun fullScreen_togglesVisibility() {
- waitForDocumentLoad()
-
- // Click enter full screen button
- composeTestRule.onNodeWithContentDescription("Enter Full Screen").performClick()
-
- // Verify exit full screen button appears
- composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertIsDisplayed()
-
- // Click exit full screen
- composeTestRule.onNodeWithContentDescription("Exit Full Screen").performClick()
-
- // Verify exit button is gone and enter button returns
- composeTestRule.onNodeWithContentDescription("Exit Full Screen").assertDoesNotExist()
- composeTestRule.onNodeWithContentDescription("Enter Full Screen").assertIsDisplayed()
+ private fun waitForDisplayModePreference(expected: DisplayMode) {
+ composeTestRule.waitUntil(timeoutMillis = 5_000) {
+ context.getSharedPreferences(SETTINGS_PREFS_NAME, Context.MODE_PRIVATE)
+ .getString(DISPLAY_MODE_KEY, DisplayMode.VERTICAL_SCROLL.name) == expected.name
+ }
}
- @Test
- fun darkMode_togglesState() {
- waitForDocumentLoad()
-
- // Initial state: Light mode (default from cleared prefs), so button says "Enable Dark Mode"
- composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
-
- // Toggle On
- composeTestRule.onNodeWithContentDescription("Enable Dark Mode").performClick()
-
- // State changed: Now button says "Disable Dark Mode"
- composeTestRule.onNodeWithContentDescription("Disable Dark Mode").assertIsDisplayed()
-
- // Toggle Off
- composeTestRule.onNodeWithContentDescription("Disable Dark Mode").performClick()
-
- // State changed back
- composeTestRule.onNodeWithContentDescription("Enable Dark Mode").assertIsDisplayed()
- }
-}
\ No newline at end of file
+}
diff --git a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt b/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt
index 1bfd9d1..0cefee3 100644
--- a/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt
+++ b/app/src/debug/java/com/aryan/reader/epubreader/EpubTestActivity.kt
@@ -31,8 +31,8 @@ class EpubTestActivity : ComponentActivity() {
coverImagePath = null,
onRenderModeChange = {},
customFonts = TODO(),
- onImportFont = TODO(), viewModel = TODO()
+ onImportFonts = TODO(), viewModel = TODO()
)
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 4432b4a..bee3fda 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -154,6 +154,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/assets/epub_reader.js b/app/src/main/assets/epub_reader.js
index 2075568..bf37155 100644
--- a/app/src/main/assets/epub_reader.js
+++ b/app/src/main/assets/epub_reader.js
@@ -2924,6 +2924,29 @@
};
const HL_LOG_TAG = "HIGHLIGHT_DEBUG";
+ const HL_RENDER_LOG_TAG = "AndroidHighlightRenderDiag";
+
+ function hlRenderPreview(value, maxLength) {
+ if (value === undefined || value === null) return "";
+ var text = String(value).replace(/\s+/g, " ");
+ var max = maxLength || 100;
+ return text.length > max ? text.substring(0, max) : text;
+ }
+
+ function hlRenderLocatorLabel(locator) {
+ locator = locator || {};
+ return "locatorChapter=" + (locator.chapterIndex === undefined || locator.chapterIndex === null ? "null" : locator.chapterIndex) +
+ " locatorPage=" + (locator.pageIndex === undefined || locator.pageIndex === null ? "null" : locator.pageIndex) +
+ " locatorOffsets=" + (locator.startOffset === undefined || locator.startOffset === null ? "null" : locator.startOffset) +
+ ".." + (locator.endOffset === undefined || locator.endOffset === null ? "null" : locator.endOffset) +
+ " locatorBlock=" + (locator.blockIndex === undefined || locator.blockIndex === null ? "null" : locator.blockIndex) +
+ " locatorChar=" + (locator.charOffset === undefined || locator.charOffset === null ? "null" : locator.charOffset) +
+ " locatorCfi=" + hlRenderPreview(locator.cfi || "", 120);
+ }
+
+ function hlRenderLog(message) {
+ console.log(HL_RENDER_LOG_TAG + ": " + message);
+ }
window.HighlightBridgeHelper = {
updateHighlightStyle: function (cfi, newColorClass, colorId) {
@@ -3280,11 +3303,13 @@
try {
var highlights = JSON.parse(jsonArrayString);
var self = this;
+ hlRenderLog("webview_restore_start count=" + highlights.length);
highlights.forEach(function (h) {
- self.applyHighlight(h.cfi, h.text, h.cssClass);
+ self.applyHighlightObject(h);
});
} catch (e) {
+ hlRenderLog("webview_restore_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log(
`$ {
HL_LOG_TAG
@@ -3295,136 +3320,376 @@
}
},
- applyHighlight: function (cfi, text, cssClass) {
+ applyHighlightObject: function (highlight) {
+ if (!highlight) return;
+ hlRenderLog(
+ "webview_apply_object id=" + (highlight.id || "") +
+ " cfi=" + hlRenderPreview(highlight.cfi || "", 120) +
+ " textLen=" + String(highlight.text || "").length +
+ " text='" + hlRenderPreview(highlight.text || "", 80) + "' " +
+ hlRenderLocatorLabel(highlight.locator || {})
+ );
+ this.applyHighlight(highlight.cfi, highlight.text, highlight.cssClass, highlight.locator || null);
+ },
+
+ highlightTextRoot: function () {
+ return document.getElementById("content-container") || document.body;
+ },
+
+ highlightTextNodes: function (root) {
+ var nodes = [];
+ if (!root) return nodes;
+ var walker = document.createTreeWalker(
+ root,
+ NodeFilter.SHOW_TEXT,
+ {
+ acceptNode: function (node) {
+ if (!node || !node.nodeValue) return NodeFilter.FILTER_REJECT;
+ var parent = node.parentElement;
+ if (!parent) return NodeFilter.FILTER_REJECT;
+ if (parent.closest && parent.closest("script, style, noscript")) return NodeFilter.FILTER_REJECT;
+ return NodeFilter.FILTER_ACCEPT;
+ },
+ },
+ false,
+ );
+ while (walker.nextNode()) nodes.push(walker.currentNode);
+ return nodes;
+ },
+
+ rangeFromTextOffsets: function (root, startOffset, endOffset) {
+ var start = parseInt(startOffset, 10);
+ var end = parseInt(endOffset, 10);
+ if (!root || !isFinite(start) || !isFinite(end) || end <= start) {
+ hlRenderLog(
+ "webview_range_offsets_skip reason=invalid_offsets start=" + startOffset +
+ " end=" + endOffset + " hasRoot=" + !!root
+ );
+ return null;
+ }
+
+ var nodes = this.highlightTextNodes(root);
+ var cursor = 0;
+ var startNode = null;
+ var startInNode = 0;
+ var endNode = null;
+ var endInNode = 0;
+
+ for (var i = 0; i < nodes.length; i++) {
+ var value = nodes[i].nodeValue || "";
+ var next = cursor + value.length;
+ if (!startNode && start >= cursor && start <= next) {
+ startNode = nodes[i];
+ startInNode = Math.max(0, Math.min(value.length, start - cursor));
+ }
+ if (startNode && end >= cursor && end <= next) {
+ endNode = nodes[i];
+ endInNode = Math.max(0, Math.min(value.length, end - cursor));
+ break;
+ }
+ cursor = next;
+ }
+
+ if (!startNode || !endNode) {
+ hlRenderLog(
+ "webview_range_offsets_skip reason=missing_boundary start=" + start +
+ " end=" + end + " nodes=" + nodes.length + " textCursor=" + cursor
+ );
+ return null;
+ }
+ var range = document.createRange();
+ range.setStart(startNode, startInNode);
+ range.setEnd(endNode, endInNode);
+ if (range.collapsed) {
+ hlRenderLog("webview_range_offsets_skip reason=collapsed start=" + start + " end=" + end);
+ return null;
+ }
+ hlRenderLog(
+ "webview_range_offsets_result start=" + start + " end=" + end +
+ " text='" + hlRenderPreview(range.toString(), 80) + "'"
+ );
+ return range;
+ },
+
+ rangeMatchesText: function (range, text) {
+ if (!range || !text) return true;
+ var actual = (range.toString() || "").trim();
+ var expected = String(text || "").trim();
+ if (!expected) return true;
+ return actual === expected || actual.replace(/\s+/g, " ") === expected.replace(/\s+/g, " ");
+ },
+
+ rangeFromVisibleTextSearch: function (text, locator) {
+ if (!text) {
+ hlRenderLog("webview_text_search_skip reason=empty_text " + hlRenderLocatorLabel(locator));
+ return null;
+ }
+ var root = this.highlightTextRoot();
+ var nodes = this.highlightTextNodes(root);
+ if (!nodes.length) {
+ hlRenderLog("webview_text_search_skip reason=no_nodes " + hlRenderLocatorLabel(locator));
+ return null;
+ }
+
+ var fullText = nodes.map(function (node) { return node.nodeValue || ""; }).join("");
+ var expected = String(text);
+ var candidates = [];
+ var index = fullText.indexOf(expected);
+ while (index !== -1) {
+ candidates.push(index);
+ index = fullText.indexOf(expected, index + 1);
+ }
+ if (!candidates.length) {
+ var lowerFull = fullText.toLowerCase();
+ var lowerExpected = expected.toLowerCase();
+ index = lowerFull.indexOf(lowerExpected);
+ while (index !== -1) {
+ candidates.push(index);
+ index = lowerFull.indexOf(lowerExpected, index + 1);
+ }
+ }
+ if (!candidates.length && expected.length > 20) {
+ var partial = expected.substring(0, Math.min(expected.length, 40));
+ index = fullText.indexOf(partial);
+ while (index !== -1) {
+ candidates.push(index);
+ index = fullText.indexOf(partial, index + 1);
+ }
+ }
+ if (!candidates.length) {
+ hlRenderLog(
+ "webview_text_search_skip reason=no_candidates textLen=" + expected.length +
+ " text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
+ );
+ return null;
+ }
+
+ var preferred = locator && locator.startOffset !== undefined && locator.startOffset !== null
+ ? parseInt(locator.startOffset, 10)
+ : candidates[0];
+ if (!isFinite(preferred)) preferred = candidates[0];
+ var best = candidates.reduce(function (currentBest, candidate) {
+ return Math.abs(candidate - preferred) < Math.abs(currentBest - preferred) ? candidate : currentBest;
+ }, candidates[0]);
+ hlRenderLog(
+ "webview_text_search_candidate count=" + candidates.length +
+ " preferred=" + preferred + " best=" + best +
+ " text='" + hlRenderPreview(expected, 80) + "' " + hlRenderLocatorLabel(locator)
+ );
+ return this.rangeFromTextOffsets(root, best, best + expected.length);
+ },
+
+ rangeFromLocator: function (locator, text) {
+ if (!locator) {
+ hlRenderLog("webview_locator_skip reason=missing_locator");
+ return null;
+ }
+ if (locator.cfi && String(locator.cfi).charAt(0) === "/") {
+ hlRenderLog(
+ "webview_locator_skip reason=structural_cfi_prefers_cfi " +
+ hlRenderLocatorLabel(locator)
+ );
+ return null;
+ }
+ var root = this.highlightTextRoot();
+ var range = this.rangeFromTextOffsets(root, locator.startOffset, locator.endOffset);
+ if (range && this.rangeMatchesText(range, text)) {
+ hlRenderLog(
+ "webview_locator_result matched=true text='" + hlRenderPreview(range.toString(), 80) + "' " +
+ hlRenderLocatorLabel(locator)
+ );
+ return range;
+ }
+ hlRenderLog(
+ "webview_locator_skip reason=" + (range ? "text_mismatch" : "range_missing") +
+ " rangeText='" + hlRenderPreview(range ? range.toString() : "", 80) + "' " +
+ "expected='" + hlRenderPreview(text || "", 80) + "' " + hlRenderLocatorLabel(locator)
+ );
+ return null;
+ },
+
+ rangeFromCfi: function (cfi, text) {
+ if (!cfi || cfi.indexOf("desktop:") === 0) {
+ hlRenderLog("webview_cfi_skip reason=unsupported cfi=" + hlRenderPreview(cfi || "", 120));
+ return null;
+ }
+ var sourceCfi = String(cfi).split("|")[0];
+ const location = window.getNodeAndOffsetFromCfi(sourceCfi);
+ if (!location || !location.node) {
+ hlRenderLog("webview_cfi_skip reason=missing_location cfi=" + hlRenderPreview(cfi, 120));
+ return null;
+ }
+
+ let startNode = location.node;
+ let startOffset = location.offset;
+
+ if (startNode.nodeType === Node.TEXT_NODE) {
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
+ walker.currentNode = startNode;
+
+ while (startNode && startOffset >= startNode.nodeValue.length) {
+ if (startOffset === startNode.nodeValue.length) {
+ const next = walker.nextNode();
+
+ if (next) {
+ startOffset -= startNode.nodeValue.length;
+ startNode = next;
+ } else {
+ break;
+ }
+ } else {
+ startOffset -= startNode.nodeValue.length;
+ startNode = walker.nextNode();
+ }
+ }
+ }
+
+ if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
+ const nodeVal = startNode.nodeValue;
+ const substring = nodeVal.substring(startOffset, startOffset + text.length);
+
+ if (substring !== text && substring.trim() !== text.trim()) {
+ hlRenderLog(
+ "webview_cfi_text_mismatch cfi=" + hlRenderPreview(cfi, 120) +
+ " startOffset=" + startOffset +
+ " actual='" + hlRenderPreview(substring, 80) + "'" +
+ " expected='" + hlRenderPreview(text, 80) + "'"
+ );
+ console.log("HIGHLIGHT_DEBUG: Text mismatch at CFI. Searching nearby.");
+ const foundIndex = nodeVal.indexOf(text);
+
+ if (foundIndex !== -1) {
+ hlRenderLog(
+ "webview_cfi_text_adjust reason=full_match oldStartOffset=" + startOffset +
+ " newStartOffset=" + foundIndex + " cfi=" + hlRenderPreview(cfi, 120)
+ );
+ startOffset = foundIndex;
+ } else {
+ const partial = text.substring(0, Math.min(text.length, 20));
+ const partialIndex = nodeVal.indexOf(partial);
+
+ if (partialIndex !== -1) {
+ hlRenderLog(
+ "webview_cfi_text_adjust reason=partial_match oldStartOffset=" + startOffset +
+ " newStartOffset=" + partialIndex + " cfi=" + hlRenderPreview(cfi, 120)
+ );
+ startOffset = partialIndex;
+ }
+ }
+ }
+ }
+
+ if (!startNode) {
+ hlRenderLog("webview_cfi_skip reason=missing_start_node cfi=" + hlRenderPreview(cfi, 120));
+ return null;
+ }
+
+ const range = document.createRange();
+
+ if (startNode.nodeType === Node.TEXT_NODE && startOffset > startNode.nodeValue.length) {
+ startOffset = Math.max(0, startNode.nodeValue.length - 1);
+ }
+
+ range.setStart(startNode, startOffset);
+
+ const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
+ treeWalker.currentNode = startNode;
+
+ let remainingLen = text.length;
+ let endNode = treeWalker.currentNode;
+ let endOffset = startOffset;
+
+ while (remainingLen > 0 && endNode) {
+ let avail = endNode.nodeValue.length - endOffset;
+
+ if (avail >= remainingLen) {
+ endOffset += remainingLen;
+ remainingLen = 0;
+ } else {
+ remainingLen -= avail;
+ endNode = treeWalker.nextNode();
+ endOffset = 0;
+ }
+ }
+
+ if (!endNode) {
+ hlRenderLog("webview_cfi_skip reason=missing_end_node cfi=" + hlRenderPreview(cfi, 120));
+ return null;
+ }
+ range.setEnd(endNode, endOffset);
+ if (range.collapsed) {
+ hlRenderLog("webview_cfi_skip reason=collapsed cfi=" + hlRenderPreview(cfi, 120));
+ return null;
+ }
+ hlRenderLog(
+ "webview_cfi_result cfi=" + hlRenderPreview(cfi, 120) +
+ " text='" + hlRenderPreview(range.toString(), 80) + "'"
+ );
+ return range;
+ },
+
+ applyHighlight: function (cfi, text, cssClass, locator) {
try {
+ cssClass = cssClass || "user-highlight-yellow";
var alreadyApplied = false;
var spans = document.querySelectorAll(`span[data-cfi]`);
for (var i = 0; i < spans.length; i++) {
- if ((spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
+ if (cfi && (spans[i].getAttribute("data-cfi") || "").split(";;").includes(cfi)) {
alreadyApplied = true;
break;
}
}
- if (alreadyApplied) return;
-
- const location = window.getNodeAndOffsetFromCfi(cfi);
- if (!location || !location.node) return;
-
- let startNode = location.node;
- let startOffset = location.offset;
-
- if (startNode.nodeType === Node.TEXT_NODE) {
- const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
- walker.currentNode = startNode;
-
- while (startNode && startOffset >= startNode.nodeValue.length) {
- if (startOffset === startNode.nodeValue.length) {
- const next = walker.nextNode();
-
- if (next) {
- startOffset -= startNode.nodeValue.length;
- startNode = next;
- } else {
- break;
- }
- } else {
- startOffset -= startNode.nodeValue.length;
- startNode = walker.nextNode();
- }
- }
+ if (alreadyApplied) {
+ hlRenderLog(
+ "webview_apply_skip reason=already_applied cfi=" + hlRenderPreview(cfi || "", 120) +
+ " textLen=" + String(text || "").length + " " + hlRenderLocatorLabel(locator)
+ );
+ return;
}
- // 1. Text Verification / Healing
- if (text && text.length > 0 && startNode && startNode.nodeType === Node.TEXT_NODE) {
- const nodeVal = startNode.nodeValue;
- // Check if text matches at exact offset
- const substring = nodeVal.substring(startOffset, startOffset + text.length);
-
- // Allow for some whitespace looseness (trim comparison)
- if (substring !== text && substring.trim() !== text.trim()) {
- console.log(`$ {
- HL_LOG_TAG
- }
-
- : Text mismatch at CFI. Searching nearby... Expected: '${text.substring(0, 10)}...', Found: '${substring.substring(0, 10)}...' `);
-
- // Try finding the text in the whole node
- const foundIndex = nodeVal.indexOf(text);
-
- if (foundIndex !== -1) {
- console.log(`$ {
- HL_LOG_TAG
- }
-
- : Found text elsewhere in node. Adjusting offset from $ {
- startOffset
- }
-
- to $ {
- foundIndex
- }
-
- .`);
- startOffset = foundIndex;
- } else {
- // Simple fuzzy: Try finding first 20 chars
- const partial = text.substring(0, Math.min(text.length, 20));
- const partialIndex = nodeVal.indexOf(partial);
-
- if (partialIndex !== -1) {
- console.log(`$ {
- HL_LOG_TAG
- }
-
- : Found partial match. Adjusting offset.`);
- startOffset = partialIndex;
- }
- }
- }
+ var hasPreciseLocator = locator &&
+ locator.startOffset !== undefined && locator.startOffset !== null &&
+ locator.endOffset !== undefined && locator.endOffset !== null &&
+ parseInt(locator.endOffset, 10) > parseInt(locator.startOffset, 10);
+ hlRenderLog(
+ "webview_apply_start cfi=" + hlRenderPreview(cfi || "", 120) +
+ " textLen=" + String(text || "").length +
+ " cssClass=" + cssClass +
+ " hasPreciseLocator=" + !!hasPreciseLocator + " " +
+ hlRenderLocatorLabel(locator)
+ );
+ var rangeSource = "locator";
+ var sourceCfi = (locator && locator.cfi) || cfi;
+ var hasSourceCfi = sourceCfi && String(sourceCfi).charAt(0) === "/";
+ var range = hasSourceCfi ? null : this.rangeFromLocator(locator, text);
+ if (!range && hasSourceCfi) {
+ rangeSource = "cfi";
+ range = this.rangeFromCfi(sourceCfi, text || "");
}
-
- if (!startNode) return;
-
- const range = document.createRange();
-
- // Set Start
- if (startNode.nodeType === Node.TEXT_NODE) {
- // Ensure offset is valid
- if (startOffset > startNode.nodeValue.length) {
- startOffset = Math.max(0, startNode.nodeValue.length - 1);
- }
+ if (!range) {
+ rangeSource = "locator";
+ range = this.rangeFromLocator(locator, text);
}
-
- range.setStart(startNode, startOffset);
-
- const treeWalker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false);
- treeWalker.currentNode = startNode;
-
- let currentNode = treeWalker.currentNode;
- let remainingOffset = startOffset;
- let remainingLen = text.length;
- let endNode = currentNode;
- let endOffset = startOffset;
-
- while (remainingLen > 0 && endNode) {
- let avail = endNode.nodeValue.length - endOffset;
-
- if (avail >= remainingLen) {
- endOffset += remainingLen;
- remainingLen = 0;
- } else {
- remainingLen -= avail;
- endNode = treeWalker.nextNode();
- endOffset = 0;
- }
+ if (!range && !hasPreciseLocator && !hasSourceCfi) {
+ rangeSource = "text_search";
+ range = this.rangeFromVisibleTextSearch(text || "", locator);
}
-
- if (endNode) {
- range.setEnd(endNode, endOffset);
- var normalizedRange = this.normalizeRangeBoundaries(range);
- this.highlightRangeSafe(normalizedRange, cssClass, cfi);
+ if (!range) {
+ hlRenderLog(
+ "webview_apply_skip reason=no_range cfi=" + hlRenderPreview(cfi || "", 120) +
+ " hasPreciseLocator=" + !!hasPreciseLocator + " " + hlRenderLocatorLabel(locator)
+ );
+ return;
}
+ var normalizedRange = this.normalizeRangeBoundaries(range);
+ this.highlightRangeSafe(normalizedRange, cssClass, cfi);
+ hlRenderLog(
+ "webview_apply_result applied=true cfi=" + hlRenderPreview(cfi || "", 120) +
+ " source=" + rangeSource +
+ " renderedText='" + hlRenderPreview(normalizedRange.toString(), 80) + "'"
+ );
} catch (e) {
+ hlRenderLog("webview_apply_error error=" + hlRenderPreview(e && e.message ? e.message : e, 160));
console.log(e);
}
},
diff --git a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt
index f050e92..d46c3d5 100644
--- a/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt
+++ b/app/src/main/java/com/aryan/reader/AndroidSettingsHubModels.kt
@@ -15,6 +15,8 @@ fun androidSettingsHubInput(
val supportsOssAiKeys = isOssBuild && !isOfflineBuild
val featurePolicy = if (isOfflineBuild) {
SharedFeaturePolicy.OssOffline
+ } else if (isOssBuild) {
+ SharedFeaturePolicy.OssOnline
} else {
SharedFeaturePolicy.Standard
}
diff --git a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt
index 4f0bb68..b97a93e 100644
--- a/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt
+++ b/app/src/main/java/com/aryan/reader/AndroidSharedStateBridge.kt
@@ -23,7 +23,8 @@ internal object AndroidSharedStateBridge {
val sharedInput = SharedLibraryProjectionInput(
state = projectionState.toSharedReaderScreenState(
rawBooks = taggedBooks,
- dbTags = input.dbTags
+ dbTags = input.dbTags,
+ includeReaderAnnotations = false
),
booksFromStore = taggedBooks
.filterNot { it.bookId.endsWith("_reflow") }
@@ -195,7 +196,8 @@ internal object AndroidSharedStateBridge {
private fun ReaderScreenState.toBridgeSharedState(projectedState: ReaderScreenState): SharedReaderScreenState {
return toSharedReaderScreenState(
rawBooks = projectedState.rawLibraryFiles.ifEmpty { rawLibraryFiles },
- dbTags = projectedState.allTags.ifEmpty { allTags }
+ dbTags = projectedState.allTags.ifEmpty { allTags },
+ includeReaderAnnotations = false
)
}
diff --git a/app/src/main/java/com/aryan/reader/AppNavigation.kt b/app/src/main/java/com/aryan/reader/AppNavigation.kt
index 5f4bbee..18780d4 100644
--- a/app/src/main/java/com/aryan/reader/AppNavigation.kt
+++ b/app/src/main/java/com/aryan/reader/AppNavigation.kt
@@ -21,6 +21,7 @@ package com.aryan.reader
import android.os.Build
import timber.log.Timber
+import androidx.activity.compose.BackHandler
import androidx.annotation.RequiresApi
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
@@ -80,6 +81,18 @@ object AppDestinations {
const val SETTINGS_SCREEN_ROUTE = "settings_screen_route"
}
+fun shouldInterceptAppNavBack(
+ currentRoute: String?,
+ hasPreviousBackStackEntry: Boolean,
+ isCurrentEntryResumed: Boolean
+): Boolean {
+ if (!hasPreviousBackStackEntry || !isCurrentEntryResumed) return false
+ return currentRoute != null &&
+ currentRoute != AppDestinations.MAIN_ROUTE &&
+ currentRoute != AppDestinations.PDF_VIEWER_ROUTE &&
+ currentRoute != AppDestinations.EPUB_READER_ROUTE
+}
+
private fun NavHostController.isReadyForBackStackChange(): Boolean {
return currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
}
@@ -166,6 +179,11 @@ fun AppNavigation(
val miniBarBottomPadding = readerTtsMiniBarBottomPaddingDp(
isOnMainRoute = currentRoute == AppDestinations.MAIN_ROUTE
).dp
+ val shouldInterceptBack = shouldInterceptAppNavBack(
+ currentRoute = currentRoute,
+ hasPreviousBackStackEntry = navController.previousBackStackEntry != null,
+ isCurrentEntryResumed = currentBackStackEntry?.lifecycle?.currentState == Lifecycle.State.RESUMED
+ )
LaunchedEffect(currentRoute, uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
if (!uiState.isLoading) {
@@ -195,6 +213,10 @@ fun AppNavigation(
}
Box(modifier = Modifier.fillMaxSize()) {
+ BackHandler(enabled = shouldInterceptBack) {
+ navController.popBackStackIfReady()
+ }
+
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
composable(AppDestinations.MAIN_ROUTE) {
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
@@ -315,7 +337,7 @@ fun AppNavigation(
},
onRenderModeChange = viewModel::setRenderMode,
customFonts = customFonts,
- onImportFont = viewModel::importFont,
+ onImportFonts = viewModel::importFonts,
viewModel = viewModel
)
diff --git a/app/src/main/java/com/aryan/reader/BookReplacementStore.kt b/app/src/main/java/com/aryan/reader/BookReplacementStore.kt
new file mode 100644
index 0000000..9252f54
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/BookReplacementStore.kt
@@ -0,0 +1,80 @@
+package com.aryan.reader
+
+import android.content.Context
+import androidx.core.content.edit
+import com.aryan.reader.shared.ReaderBookReplacementPreferences
+import com.aryan.reader.shared.ReaderBookReplacementPreferencesJson
+import com.aryan.reader.shared.ReaderWordReplacementEngine
+import com.aryan.reader.shared.ReaderWordReplacementRule
+import org.jsoup.nodes.Document
+import org.jsoup.nodes.Node
+import org.jsoup.nodes.TextNode
+
+private const val READER_PREFS_NAME = "reader_prefs"
+private const val BOOK_REPLACEMENTS_KEY = "book_word_replacements_json"
+
+fun loadBookReplacementPreferences(context: Context): ReaderBookReplacementPreferences {
+ val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
+ return ReaderBookReplacementPreferencesJson.decodeOrEmpty(prefs.getString(BOOK_REPLACEMENTS_KEY, null))
+}
+
+fun saveBookReplacementPreferences(
+ context: Context,
+ preferences: ReaderBookReplacementPreferences,
+) {
+ val prefs = context.getSharedPreferences(READER_PREFS_NAME, Context.MODE_PRIVATE)
+ prefs.edit {
+ putString(BOOK_REPLACEMENTS_KEY, ReaderBookReplacementPreferencesJson.encode(preferences))
+ }
+}
+
+internal fun applyBookReplacementsToHtmlDocument(
+ document: Document,
+ preferences: ReaderBookReplacementPreferences,
+ fileId: String?,
+): Boolean {
+ val rules = preferences.activeRulesForFile(fileId)
+ if (rules.isEmpty()) return false
+
+ var changed = false
+
+ fun rewriteTextNodes(node: Node) {
+ if (node is TextNode && !node.hasReplacementBlockedAncestor()) {
+ val original = node.wholeText
+ val replaced = applyBookReplacementRules(original, rules)
+ if (replaced != original) {
+ node.text(replaced)
+ changed = true
+ }
+ return
+ }
+
+ node.childNodes().forEach(::rewriteTextNodes)
+ }
+
+ document.body()?.let(::rewriteTextNodes)
+ return changed
+}
+
+private fun applyBookReplacementRules(
+ text: String,
+ rules: List,
+): String {
+ return ReaderWordReplacementEngine.apply(
+ text = text,
+ rules = rules,
+ ).text
+}
+
+private fun TextNode.hasReplacementBlockedAncestor(): Boolean {
+ var current: Node? = parent()
+ while (current != null) {
+ when (current.nodeName().lowercase()) {
+ "script",
+ "style",
+ "noscript" -> return true
+ }
+ current = current.parent()
+ }
+ return false
+}
diff --git a/app/src/main/java/com/aryan/reader/BookWordReplacementsSheet.kt b/app/src/main/java/com/aryan/reader/BookWordReplacementsSheet.kt
new file mode 100644
index 0000000..7e127fa
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/BookWordReplacementsSheet.kt
@@ -0,0 +1,400 @@
+package com.aryan.reader
+
+import androidx.annotation.StringRes
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardCapitalization
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.aryan.reader.shared.ReaderBookReplacementEngine
+import com.aryan.reader.shared.ReaderBookReplacementPreferences
+import com.aryan.reader.shared.ReaderWordReplacementRule
+
+private data class BookRuleEditTarget(
+ val ruleId: String? = null,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookWordReplacementsSheet(
+ isVisible: Boolean,
+ bookId: String,
+ bookTitle: String?,
+ preferences: ReaderBookReplacementPreferences,
+ onPreferencesChange: (ReaderBookReplacementPreferences) -> Unit,
+ onDismiss: () -> Unit,
+) {
+ if (!isVisible) return
+
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ var editTarget by remember(bookId) { mutableStateOf(null) }
+ val rules = preferences.rulesForFile(bookId)
+ val editingRule = editTarget?.ruleId?.let { id -> rules.firstOrNull { it.id == id } }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = sheetState,
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 720.dp)
+ .imePadding()
+ .padding(horizontal = 20.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = stringResource(R.string.menu_book_word_replacements),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = bookTitle?.takeIf { it.isNotBlank() } ?: stringResource(R.string.book_replacements_current_book),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ IconButton(onClick = onDismiss) {
+ Icon(Icons.Default.Close, contentDescription = stringResource(R.string.action_close))
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ LazyColumn(
+ modifier = Modifier.heightIn(max = 560.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ item {
+ TextButton(
+ onClick = { editTarget = BookRuleEditTarget() },
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(stringResource(R.string.book_replacements_add_rule))
+ }
+ }
+ if (editTarget != null) {
+ item {
+ BookRuleEditorCard(
+ seedRule = editingRule,
+ onCancel = { editTarget = null },
+ onSave = { rule ->
+ val updatedRules = if (editingRule == null) {
+ rules + rule
+ } else {
+ rules.map { if (it.id == editingRule.id) rule else it }
+ }
+ onPreferencesChange(preferences.withFileRules(bookId, updatedRules))
+ editTarget = null
+ },
+ )
+ }
+ }
+ item {
+ BookReplacementRuleList(
+ rules = rules,
+ emptyTextRes = R.string.book_replacements_empty,
+ onToggle = { rule, enabled ->
+ onPreferencesChange(
+ preferences.withFileRules(
+ bookId,
+ rules.map { if (it.id == rule.id) it.copy(enabled = enabled) else it },
+ ),
+ )
+ },
+ onEdit = { editTarget = BookRuleEditTarget(it.id) },
+ onDelete = { rule ->
+ onPreferencesChange(preferences.withFileRules(bookId, rules.filterNot { it.id == rule.id }))
+ },
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+}
+
+@Composable
+private fun BookRuleEditorCard(
+ seedRule: ReaderWordReplacementRule?,
+ onCancel: () -> Unit,
+ onSave: (ReaderWordReplacementRule) -> Unit,
+) {
+ val draftRuleId = remember(seedRule?.id) { seedRule?.id ?: newBookReplacementRuleId() }
+ val initial = seedRule ?: ReaderWordReplacementRule(
+ id = draftRuleId,
+ from = "",
+ to = "",
+ )
+ var from by remember(initial.id) { mutableStateOf(initial.from) }
+ var to by remember(initial.id) { mutableStateOf(initial.to) }
+ var enabled by remember(initial.id) { mutableStateOf(initial.enabled) }
+ var isRegex by remember(initial.id) { mutableStateOf(initial.isRegex) }
+ var wholeWord by remember(initial.id) { mutableStateOf(initial.wholeWord) }
+ var matchCase by remember(initial.id) { mutableStateOf(initial.matchCase) }
+ val defaultPreviewInput = stringResource(R.string.book_replacements_preview_default)
+ var previewInput by remember(initial.id, defaultPreviewInput) {
+ mutableStateOf(initial.from.takeIf { it.isNotBlank() } ?: defaultPreviewInput)
+ }
+
+ val draft = ReaderWordReplacementRule(
+ id = initial.id,
+ from = from,
+ to = to,
+ enabled = enabled,
+ isRegex = isRegex,
+ matchCase = matchCase,
+ wholeWord = wholeWord,
+ )
+ val validation = ReaderBookReplacementEngine.validate(draft)
+ val previewOutput = if (validation.isValid) {
+ ReaderBookReplacementEngine.apply(
+ text = previewInput,
+ preferences = ReaderBookReplacementPreferences(fileRules = mapOf("preview" to listOf(draft.copy(enabled = true)))),
+ fileId = "preview",
+ ).text
+ } else {
+ previewInput
+ }
+
+ Card(
+ shape = RoundedCornerShape(8.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.35f)),
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ Text(
+ text = stringResource(if (seedRule == null) R.string.book_replacements_new_replacement else R.string.book_replacements_edit_replacement),
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ OutlinedTextField(
+ value = from,
+ onValueChange = { from = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.tts_replacements_label_replace)) },
+ singleLine = !isRegex,
+ isError = !validation.isValid,
+ supportingText = if (validation.message != null) {
+ { Text(validation.message.orEmpty()) }
+ } else {
+ null
+ },
+ keyboardOptions = KeyboardOptions(
+ capitalization = KeyboardCapitalization.None,
+ keyboardType = KeyboardType.Text,
+ ),
+ )
+ OutlinedTextField(
+ value = to,
+ onValueChange = { to = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.book_replacements_label_with)) },
+ singleLine = !isRegex,
+ )
+ LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ item {
+ FilterChip(
+ selected = enabled,
+ onClick = { enabled = !enabled },
+ label = { Text(stringResource(R.string.tts_replacements_chip_enabled)) },
+ leadingIcon = if (enabled) {
+ { Icon(Icons.Default.Check, contentDescription = null) }
+ } else {
+ null
+ },
+ )
+ }
+ item {
+ FilterChip(
+ selected = isRegex,
+ onClick = { isRegex = !isRegex },
+ label = { Text(stringResource(R.string.tts_replacements_chip_regex)) },
+ )
+ }
+ item {
+ FilterChip(
+ selected = wholeWord,
+ onClick = { wholeWord = !wholeWord },
+ label = { Text(stringResource(R.string.tts_replacements_chip_whole_word)) },
+ )
+ }
+ item {
+ FilterChip(
+ selected = matchCase,
+ onClick = { matchCase = !matchCase },
+ label = { Text(stringResource(R.string.tts_replacements_chip_match_case)) },
+ )
+ }
+ }
+ OutlinedTextField(
+ value = previewInput,
+ onValueChange = { previewInput = it },
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(R.string.tts_replacements_label_preview_input)) },
+ minLines = 2,
+ )
+ Text(
+ text = previewOutput,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End,
+ ) {
+ TextButton(onClick = onCancel) {
+ Text(stringResource(R.string.action_cancel))
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ Button(
+ onClick = { onSave(draft) },
+ enabled = validation.isValid,
+ ) {
+ Text(stringResource(R.string.action_save))
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun BookReplacementRuleList(
+ rules: List,
+ @StringRes emptyTextRes: Int,
+ onToggle: (ReaderWordReplacementRule, Boolean) -> Unit,
+ onEdit: (ReaderWordReplacementRule) -> Unit,
+ onDelete: (ReaderWordReplacementRule) -> Unit,
+) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = stringResource(R.string.tts_replacements_rules),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.SemiBold,
+ )
+ if (rules.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(emptyTextRes),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ return
+ }
+ rules.forEach { rule ->
+ val emptyLabel = stringResource(R.string.book_replacements_empty_replacement)
+ ListItem(
+ headlineContent = {
+ Text(
+ text = rule.summaryText(emptyLabel),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ },
+ supportingContent = {
+ Text(rule.optionSummary())
+ },
+ trailingContent = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Switch(
+ checked = rule.enabled,
+ onCheckedChange = { onToggle(rule, it) },
+ )
+ IconButton(onClick = { onEdit(rule) }) {
+ Icon(Icons.Default.Edit, contentDescription = stringResource(R.string.action_edit))
+ }
+ IconButton(onClick = { onDelete(rule) }) {
+ Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.action_delete))
+ }
+ }
+ },
+ )
+ }
+ }
+}
+
+private fun ReaderWordReplacementRule.summaryText(emptyLabel: String): String {
+ val replacement = to.ifBlank { emptyLabel }
+ return "$from -> $replacement"
+}
+
+@Composable
+private fun ReaderWordReplacementRule.optionSummary(): String {
+ val regexLabel = stringResource(R.string.tts_replacements_chip_regex)
+ val plainTextLabel = stringResource(R.string.tts_replacements_plain_text)
+ val wholeWordLabel = stringResource(R.string.tts_replacements_chip_whole_word)
+ val caseSensitiveLabel = stringResource(R.string.tts_replacements_case_sensitive)
+ val parts = buildList {
+ add(if (isRegex) regexLabel else plainTextLabel)
+ if (wholeWord) add(wholeWordLabel)
+ if (matchCase) add(caseSensitiveLabel)
+ }
+ return parts.joinToString(" - ")
+}
+
+private fun newBookReplacementRuleId(): String {
+ return "book_rule_${System.currentTimeMillis()}"
+}
diff --git a/app/src/main/java/com/aryan/reader/CloudEpubAnnotationMetadata.kt b/app/src/main/java/com/aryan/reader/CloudEpubAnnotationMetadata.kt
new file mode 100644
index 0000000..8b92be3
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/CloudEpubAnnotationMetadata.kt
@@ -0,0 +1,51 @@
+package com.aryan.reader
+
+import com.aryan.reader.data.BookMetadata
+import com.aryan.reader.data.RecentFileItem
+
+internal fun RecentFileItem.needsRemoteEpubAnnotationMetadataGuard(): Boolean {
+ return type in EPUB_READER_FILE_TYPES &&
+ (bookmarksJson.isNullOrBlank() || highlightsJson.isNullOrBlank())
+}
+
+internal fun RecentFileItem.mergeRemoteEpubAnnotationMetadata(remote: BookMetadata?): RecentFileItem {
+ if (remote == null || remote.isDeleted || type !in EPUB_READER_FILE_TYPES || !remote.isEpubReaderMetadata()) {
+ return this
+ }
+ val nextBookmarks = if (bookmarksJson.isNullOrBlank() && remote.bookmarksJson.hasCloudAnnotationPayload()) {
+ remote.bookmarksJson
+ } else {
+ bookmarksJson
+ }
+ val nextHighlights = if (highlightsJson.isNullOrBlank() && remote.highlightsJson.hasCloudAnnotationPayload()) {
+ remote.highlightsJson
+ } else {
+ highlightsJson
+ }
+ if (nextBookmarks == bookmarksJson && nextHighlights == highlightsJson) return this
+ return copy(
+ bookmarksJson = nextBookmarks,
+ highlightsJson = nextHighlights
+ )
+}
+
+private fun BookMetadata.isEpubReaderMetadata(): Boolean {
+ val remoteType = runCatching { FileType.valueOf(type) }.getOrNull() ?: return false
+ return remoteType in EPUB_READER_FILE_TYPES
+}
+
+internal fun String?.hasCloudAnnotationPayload(): Boolean {
+ val normalized = this?.trim().orEmpty()
+ return normalized.isNotEmpty() && normalized != "[]"
+}
+
+internal fun annotationJsonEquivalentForNoop(existing: String?, incoming: String): Boolean {
+ val existingNormalized = existing?.trim().orEmpty()
+ val incomingNormalized = incoming.trim()
+ if (existingNormalized == incomingNormalized) return true
+ return existingNormalized.isAnnotationJsonEmpty() && incomingNormalized.isAnnotationJsonEmpty()
+}
+
+private fun String.isAnnotationJsonEmpty(): Boolean {
+ return isBlank() || this == "[]"
+}
diff --git a/app/src/main/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisions.kt b/app/src/main/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisions.kt
new file mode 100644
index 0000000..2976b8c
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/CloudPdfAnnotationSidecarDecisions.kt
@@ -0,0 +1,75 @@
+package com.aryan.reader
+
+import java.io.File
+
+internal data class AndroidPdfCloudSidecarState(
+ val hasInk: Boolean,
+ val inkTimestamp: Long,
+ val hasDeletedInk: Boolean = false,
+ val deletedInkTimestamp: Long = 0L,
+ val hasRichText: Boolean,
+ val richTextTimestamp: Long,
+ val hasLayout: Boolean,
+ val layoutTimestamp: Long,
+ val hasTextBoxes: Boolean,
+ val textBoxesTimestamp: Long,
+ val hasHighlights: Boolean,
+ val highlightsTimestamp: Long
+) {
+ val hasAnnotationPayload: Boolean
+ get() = hasInk || hasDeletedInk || hasRichText || hasTextBoxes || hasHighlights
+
+ val annotationPayloadTimestamp: Long
+ get() = maxOf(
+ inkTimestamp.takeIf { hasInk } ?: 0L,
+ deletedInkTimestamp.takeIf { hasDeletedInk } ?: 0L,
+ richTextTimestamp.takeIf { hasRichText } ?: 0L,
+ textBoxesTimestamp.takeIf { hasTextBoxes } ?: 0L,
+ highlightsTimestamp.takeIf { hasHighlights } ?: 0L
+ )
+
+ val bundleTimestamp: Long
+ get() = if (hasAnnotationPayload) {
+ maxOf(annotationPayloadTimestamp, layoutTimestamp.takeIf { hasLayout } ?: 0L)
+ } else {
+ 0L
+ }
+}
+
+internal fun shouldUploadLocalPdfCloudAnnotations(
+ localSidecars: AndroidPdfCloudSidecarState,
+ remoteHasAnnotations: Boolean,
+ remoteAnnotationModifiedTimestamp: Long
+): Boolean {
+ return localSidecars.hasAnnotationPayload &&
+ (!remoteHasAnnotations || localSidecars.annotationPayloadTimestamp > remoteAnnotationModifiedTimestamp)
+}
+
+internal fun shouldDownloadRemotePdfCloudAnnotations(
+ localSidecars: AndroidPdfCloudSidecarState,
+ localAnnotationsShouldUpload: Boolean,
+ remoteHasAnnotations: Boolean,
+ remoteAnnotationModifiedTimestamp: Long
+): Boolean {
+ if (localAnnotationsShouldUpload || !remoteHasAnnotations) return false
+ return !localSidecars.hasAnnotationPayload ||
+ remoteAnnotationModifiedTimestamp > localSidecars.annotationPayloadTimestamp
+}
+
+internal fun File?.hasSyncableCloudAnnotationPayload(): Boolean {
+ val file = this ?: return false
+ if (!file.isFile || file.length() <= 0L) return false
+ val trimmed = runCatching { file.readText().trim() }.getOrDefault("")
+ return trimmed.isNotBlank() && trimmed != "[]" && trimmed != "{}"
+}
+
+internal fun markPdfCloudAnnotationSidecarsSynced(timestamp: Long, vararg files: File?) {
+ if (timestamp <= 0L) return
+ files.forEach { file ->
+ if (file?.exists() == true) {
+ file.setLastModified(timestamp)
+ }
+ }
+}
+
+internal fun cloudPdfAnnotationDriveFileName(bookId: String): String = "annotation_$bookId.json"
diff --git a/app/src/main/java/com/aryan/reader/CloudSyncTrace.kt b/app/src/main/java/com/aryan/reader/CloudSyncTrace.kt
new file mode 100644
index 0000000..8702480
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/CloudSyncTrace.kt
@@ -0,0 +1,70 @@
+package com.aryan.reader
+
+import android.util.Log
+import com.aryan.reader.data.BookMetadata
+import com.aryan.reader.data.RecentFileItem
+import com.aryan.reader.data.effectiveAnnotationModifiedTimestamp
+import com.aryan.reader.data.effectiveReadingPositionModifiedTimestamp
+import timber.log.Timber
+
+internal const val CloudSyncTraceTag = "EpistemeCloudSync"
+internal const val CloudAnnotationSyncTraceTag = "EpistemeCloudAnnotations"
+
+internal fun logCloudSyncTrace(message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.d(CloudSyncTraceTag, text)
+ Timber.tag(CloudSyncTraceTag).d(text)
+}
+
+internal fun logCloudSyncError(error: Throwable, message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.e(CloudSyncTraceTag, text, error)
+ Timber.tag(CloudSyncTraceTag).e(error, text)
+}
+
+internal fun logCloudAnnotationSyncTrace(message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.d(CloudAnnotationSyncTraceTag, text)
+ Timber.tag(CloudAnnotationSyncTraceTag).d(text)
+}
+
+internal fun logCloudAnnotationSyncError(error: Throwable, message: () -> String) {
+ if (!BuildConfig.DEBUG) return
+ val text = message()
+ Log.e(CloudAnnotationSyncTraceTag, text, error)
+ Timber.tag(CloudAnnotationSyncTraceTag).e(error, text)
+}
+
+internal fun RecentFileItem.cloudSyncTraceSummary(prefix: String = "local"): String {
+ return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
+ "contentTs=$fileContentModifiedTimestamp " +
+ "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
+ "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
+ "bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
+}
+
+internal fun BookMetadata.cloudSyncTraceSummary(prefix: String = "remote"): String {
+ return "$prefix{id=$bookId type=$type ts=$lastModifiedTimestamp readTs=${effectiveReadingPositionModifiedTimestamp()} " +
+ "annTs=${effectiveAnnotationModifiedTimestamp()} contentTs=$fileContentModifiedTimestamp " +
+ "page=$lastPage chapter=$lastChapterIndex block=$locatorBlockIndex char=$locatorCharOffset " +
+ "progress=$progressPercentage cfi=${lastPositionCfi.cloudSyncPreview()} deleted=$isDeleted recent=$isRecent " +
+ "hasAnnotations=$hasAnnotations bookmarks=${bookmarksJson.cloudSyncAnnotationSummary()} " +
+ "highlights=${highlightsJson.cloudSyncAnnotationSummary()}}"
+}
+
+internal fun String?.cloudSyncPreview(maxLength: Int = 80): String {
+ val value = this ?: return "null"
+ return if (value.length <= maxLength) value else value.take(maxLength) + "..."
+}
+
+internal fun String?.cloudSyncAnnotationSummary(): String {
+ val value = this?.trim() ?: return "null"
+ return when {
+ value.isEmpty() -> "blank"
+ value == "[]" -> "empty"
+ else -> "present(${value.length})"
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/Common.kt b/app/src/main/java/com/aryan/reader/Common.kt
index 3f071d5..9fb3767 100644
--- a/app/src/main/java/com/aryan/reader/Common.kt
+++ b/app/src/main/java/com/aryan/reader/Common.kt
@@ -2949,7 +2949,14 @@ private fun ThemeGridItem(
Text(text = stringResource(R.string.label_aa_preview), color = textColor, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Bold)
}
Spacer(modifier = Modifier.height(8.dp))
- Text(text = theme.name, style = MaterialTheme.typography.labelSmall, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ Text(
+ text = theme.name,
+ style = MaterialTheme.typography.labelSmall,
+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.clickable { onThemeSelected(theme.id) }
+ )
if (theme.isCustom && onEdit != null && onDelete != null) {
Spacer(modifier = Modifier.height(6.dp))
diff --git a/app/src/main/java/com/aryan/reader/FileHasher.kt b/app/src/main/java/com/aryan/reader/FileHasher.kt
index 7d8e436..2142093 100644
--- a/app/src/main/java/com/aryan/reader/FileHasher.kt
+++ b/app/src/main/java/com/aryan/reader/FileHasher.kt
@@ -21,6 +21,7 @@ package com.aryan.reader
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import timber.log.Timber
import java.io.InputStream
import java.security.MessageDigest
@@ -50,9 +51,8 @@ object FileHasher {
}
hexString.toString()
} catch (e: Exception) {
- // In a real app, you'd want to log this error
- e.printStackTrace()
+ Timber.e(e, "Failed to calculate SHA-256 hash")
null
}
}
-}
\ No newline at end of file
+}
diff --git a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
index 97bdde9..7d68558 100644
--- a/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
+++ b/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
@@ -72,45 +72,27 @@ class FolderSyncWorker(
val targetFolderUri = inputData.getString(KEY_TARGET_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
- val jsonString = prefs.getString("synced_folders_list_json", null)
- val folders = mutableListOf>>()
-
- if (jsonString != null) {
- try {
- val array = org.json.JSONArray(jsonString)
- for (i in 0 until array.length()) {
- val obj = array.getJSONObject(i)
- val uri = obj.getString("uri")
- val allowedFileTypes = mutableSetOf()
- if (obj.has("allowedFileTypes")) {
- val typesArray = obj.getJSONArray("allowedFileTypes")
- for (j in 0 until typesArray.length()) {
- try { allowedFileTypes.add(FileType.valueOf(typesArray.getString(j))) } catch (_: Exception) {}
- }
- } else {
- allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
- }
- folders.add(Pair(uri, allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }))
- }
- } catch (e: Exception) { Timber.e(e) }
- } else {
- val single = prefs.getString("synced_folder_uri", null)
- if (single != null) folders.add(Pair(single, ANDROID_SYNCABLE_FILE_TYPES))
- }
+ val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
+ val folders = SyncedFolderPrefs.decodeSyncedFolders(
+ jsonString = jsonString,
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
+ syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
+ )
if (folders.isEmpty()) {
ReaderPerfLog.w("FolderSync worker aborted: no linked folders")
return Result.success()
}
+ val enabledFolders = folders.filter { it.localSyncEnabled }
val foldersToProcess = if (targetFolderUri.isNullOrBlank()) {
- folders
+ enabledFolders
} else {
- folders.filter { it.first == targetFolderUri }
+ enabledFolders.filter { it.uriString == targetFolderUri }
}
if (foldersToProcess.isEmpty()) {
- ReaderPerfLog.w("FolderSync worker aborted: target folder not linked target=$targetFolderUri")
+ ReaderPerfLog.w("FolderSync worker aborted: target folder not linked or disabled target=$targetFolderUri")
return Result.success()
}
@@ -123,8 +105,8 @@ class FolderSyncWorker(
syncMutex.withLock {
var allSuccess = true
- for ((uriString, allowedTypes) in foldersToProcess) {
- val success = performSyncForFolder(uriString, allowedTypes, isMetadataOnly)
+ for (folderConfig in foldersToProcess) {
+ val success = performSyncForFolder(folderConfig, isMetadataOnly)
if (!success) allSuccess = false
}
@@ -132,13 +114,14 @@ class FolderSyncWorker(
try {
val array = org.json.JSONArray(jsonString)
val now = System.currentTimeMillis()
+ val processedUris = foldersToProcess.mapTo(mutableSetOf()) { it.uriString }
for (i in 0 until array.length()) {
val obj = array.getJSONObject(i)
- if (targetFolderUri.isNullOrBlank() || obj.optString("uri") == targetFolderUri) {
+ if (obj.optString("uri") in processedUris) {
obj.put("lastScanTime", now)
}
}
- prefs.edit { putString("synced_folders_list_json", array.toString()) }
+ prefs.edit { putString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, array.toString()) }
} catch (_: Exception) {}
}
@@ -153,7 +136,9 @@ class FolderSyncWorker(
}
}
- private suspend fun performSyncForFolder(folderUriString: String, allowedFileTypes: Set, metadataOnly: Boolean): Boolean {
+ private suspend fun performSyncForFolder(folderConfig: SyncedFolder, metadataOnly: Boolean): Boolean {
+ val folderUriString = folderConfig.uriString
+ val allowedFileTypes = folderConfig.allowedFileTypes
if (folderUriString.isBlank()) return true
val folderUri = folderUriString.toUri()
val folderStart = ReaderPerfLog.nowNanos()
@@ -235,9 +220,10 @@ class FolderSyncWorker(
val nowMillis = System.currentTimeMillis()
val folder = SyncedFolder(
uriString = folderUriString,
- name = documentTree.name ?: "Local Folder",
+ name = documentTree.name ?: folderConfig.name,
lastScanTime = nowMillis,
- allowedFileTypes = allowedFileTypes
+ allowedFileTypes = allowedFileTypes,
+ localSyncEnabled = true
)
val sharedState = SharedReaderScreenState(
rawLibraryBooks = existingFolderBooks.map { it.toFolderSyncSharedBookItem() },
@@ -564,7 +550,8 @@ class FolderSyncWorker(
lastPageIndex = lastPage,
readerPosition = readerPositionOrNull(),
readerBookmarks = parseReaderBookmarks(),
- readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
+ readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson),
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
)
}
@@ -613,8 +600,8 @@ class FolderSyncWorker(
lastChapterIndex = legacyPosition?.chapterIndex ?: appliedMetadata?.lastChapterIndex ?: existing?.lastChapterIndex,
lastPage = legacyPosition?.pageIndex ?: lastPageIndex ?: appliedMetadata?.lastPage ?: existing?.lastPage,
lastPositionCfi = legacyPosition?.cfi ?: appliedMetadata?.lastPositionCfi ?: existing?.lastPositionCfi,
- locatorBlockIndex = appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
- locatorCharOffset = appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
+ locatorBlockIndex = legacyPosition?.blockIndex ?: appliedMetadata?.locatorBlockIndex ?: existing?.locatorBlockIndex,
+ locatorCharOffset = legacyPosition?.charOffset ?: appliedMetadata?.locatorCharOffset ?: existing?.locatorCharOffset,
progressPercentage = progressPercentage,
isRecent = isRecent,
isAvailable = true,
@@ -637,6 +624,7 @@ class FolderSyncWorker(
originalDescription = originalDescription,
folderTextMetadataParsed = folderTextMetadataParsed,
folderCoverMetadataParsed = if (contentChanged) false else existing?.folderCoverMetadataParsed ?: false,
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = existing?.tags.orEmpty()
)
}
@@ -720,18 +708,12 @@ class FolderSyncWorker(
private fun isFolderStillLinked(folderUriString: String): Boolean {
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
- val jsonString = prefs.getString("synced_folders_list_json", null)
- if (jsonString != null) {
- return try {
- val array = org.json.JSONArray(jsonString)
- (0 until array.length()).any { index ->
- array.getJSONObject(index).optString("uri") == folderUriString
- }
- } catch (_: Exception) {
- false
- }
- }
- return prefs.getString("synced_folder_uri", null) == folderUriString
+ return SyncedFolderPrefs.isLocalSyncEnabled(
+ jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
+ folderUriString = folderUriString,
+ syncableTypes = ANDROID_SYNCABLE_FILE_TYPES
+ )
}
private fun getFileType(name: String, mimeType: String?): FileType? {
diff --git a/app/src/main/java/com/aryan/reader/FontsScreen.kt b/app/src/main/java/com/aryan/reader/FontsScreen.kt
index 4e86a24..7c1caad 100644
--- a/app/src/main/java/com/aryan/reader/FontsScreen.kt
+++ b/app/src/main/java/com/aryan/reader/FontsScreen.kt
@@ -3,8 +3,11 @@
package com.aryan.reader
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -27,10 +30,12 @@ import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.CloudDownload
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExtendedFloatingActionButton
@@ -46,6 +51,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -80,37 +86,67 @@ fun FontsScreen(
val showGoogleFontsOption = !(BuildConfig.FLAVOR == "oss" && BuildConfig.IS_OFFLINE)
- // Dialog state
- var showDeleteDialog by remember { mutableStateOf(false) }
- var fontToDelete by remember { mutableStateOf(null) }
+ var fontsPendingDelete by remember { mutableStateOf>(emptyList()) }
var showGoogleFontsSheet by remember { mutableStateOf(false) }
var selectedSection by remember { mutableStateOf(SharedFontSettingsSection.READER_FONTS) }
+ var selectedFontIds by remember { mutableStateOf>(emptySet()) }
- val pickFontLauncher = rememberFilePickerLauncher { uris ->
- uris.firstOrNull()?.let { viewModel.importFont(it) }
+ val pickFontLauncher = rememberFilePickerLauncher(viewModel::importFonts)
+ val fontMimeTypes = remember { supportedFontMimeTypes() }
+ val allFontIds = remember(fonts) { fonts.mapTo(mutableSetOf()) { it.id } }
+ val selectedFonts = remember(fonts, selectedFontIds) {
+ fonts.filter { it.id in selectedFontIds }
+ }
+ val isFontSelectionMode = selectedSection == SharedFontSettingsSection.READER_FONTS && selectedFonts.isNotEmpty()
+
+ LaunchedEffect(fonts) {
+ selectedFontIds = selectedFontIds.intersect(allFontIds)
}
- val fontMimeTypes = arrayOf(
- "font/ttf", "font/otf", "font/woff2",
- "application/x-font-ttf", "application/x-font-otf",
- "application/font-woff2", "application/vnd.ms-opentype",
- "application/x-font-opentype"
- )
+ BackHandler(enabled = isFontSelectionMode) {
+ selectedFontIds = emptySet()
+ }
Scaffold(
modifier = Modifier.statusBarsPadding(),
topBar = {
- CustomTopAppBar(
- title = { Text(stringResource(R.string.custom_fonts)) },
- navigationIcon = {
- IconButton(onClick = onBackClick) {
- Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
+ if (isFontSelectionMode) {
+ ContextualTopAppBar(
+ selectedItemCount = selectedFonts.size,
+ onNavIconClick = { selectedFontIds = emptySet() },
+ onSelectAllClick = {
+ selectedFontIds = if (selectedFontIds.containsAll(allFontIds)) {
+ emptySet()
+ } else {
+ allFontIds
+ }
+ },
+ onDeleteClick = {
+ if (selectedFonts.isNotEmpty()) {
+ fontsPendingDelete = selectedFonts
+ }
}
- }
- )
+ )
+ } else {
+ CustomTopAppBar(
+ title = { Text(stringResource(R.string.custom_fonts)) },
+ navigationIcon = {
+ IconButton(onClick = onBackClick) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
+ }
+ },
+ actions = {
+ if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
+ IconButton(onClick = { selectedFontIds = allFontIds }) {
+ Icon(Icons.Default.SelectAll, contentDescription = stringResource(R.string.select_all))
+ }
+ }
+ }
+ )
+ }
},
floatingActionButton = {
- if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty()) {
+ if (selectedSection == SharedFontSettingsSection.READER_FONTS && fonts.isNotEmpty() && !isFontSelectionMode) {
Column(
horizontalAlignment = Alignment.End,
verticalArrangement = Arrangement.spacedBy(16.dp)
@@ -139,7 +175,10 @@ fun FontsScreen(
Column(modifier = Modifier.fillMaxSize()) {
SharedFontSettingsTabs(
selectedSection = selectedSection,
- onSectionChange = { selectedSection = it },
+ onSectionChange = {
+ selectedFontIds = emptySet()
+ selectedSection = it
+ },
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp)
)
@@ -166,9 +205,13 @@ fun FontsScreen(
items(fonts, key = { it.id }) { font ->
FontListItem(
font = font,
+ isSelected = font.id in selectedFontIds,
+ isSelectionMode = isFontSelectionMode,
+ onSelectionToggle = {
+ selectedFontIds = selectedFontIds.toggle(font.id)
+ },
onDelete = {
- fontToDelete = font
- showDeleteDialog = true
+ fontsPendingDelete = listOf(font)
}
)
}
@@ -208,17 +251,17 @@ fun FontsScreen(
}
}
- if (showDeleteDialog && fontToDelete != null) {
- DeleteFontConfirmationDialog(
- fontName = fontToDelete!!.displayName,
+ if (fontsPendingDelete.isNotEmpty()) {
+ DeleteFontsConfirmationDialog(
+ fonts = fontsPendingDelete,
onConfirm = {
- fontToDelete?.let { viewModel.deleteFont(it.id) }
- showDeleteDialog = false
- fontToDelete = null
+ val pendingIds = fontsPendingDelete.map { it.id }
+ viewModel.deleteFonts(pendingIds)
+ selectedFontIds = selectedFontIds - pendingIds.toSet()
+ fontsPendingDelete = emptyList()
},
onDismiss = {
- showDeleteDialog = false
- fontToDelete = null
+ fontsPendingDelete = emptyList()
}
)
}
@@ -388,9 +431,13 @@ fun GoogleFontsBottomSheet(
}
// Existing unchanged components
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun FontListItem(
font: CustomFontEntity,
+ isSelected: Boolean,
+ isSelectionMode: Boolean,
+ onSelectionToggle: () -> Unit,
onDelete: () -> Unit
) {
val customTypeface = remember(font.path) {
@@ -402,8 +449,23 @@ fun FontListItem(
}
Card(
- modifier = Modifier.fillMaxWidth(),
- colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface)
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = {
+ if (isSelectionMode) {
+ onSelectionToggle()
+ }
+ },
+ onLongClick = onSelectionToggle
+ ),
+ colors = CardDefaults.cardColors(
+ containerColor = if (isSelected) {
+ MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
+ } else {
+ MaterialTheme.colorScheme.surface
+ }
+ )
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@@ -411,17 +473,27 @@ fun FontListItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
+ if (isSelectionMode) {
+ Checkbox(
+ checked = isSelected,
+ onCheckedChange = { onSelectionToggle() },
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ }
Text(
text = font.displayName,
style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.weight(1f)
)
- IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
- Icon(
- Icons.Default.Delete,
- contentDescription = stringResource(R.string.action_delete),
- tint = MaterialTheme.colorScheme.error
- )
+ if (!isSelectionMode) {
+ IconButton(onClick = onDelete, modifier = Modifier.size(24.dp)) {
+ Icon(
+ Icons.Default.Delete,
+ contentDescription = stringResource(R.string.action_delete),
+ tint = MaterialTheme.colorScheme.error
+ )
+ }
}
}
@@ -476,15 +548,32 @@ private fun List.toSharedCustomFontItems(): List,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
+ val isSingleFont = fonts.size == 1
AlertDialog(
onDismissRequest = onDismiss,
- title = { Text(stringResource(R.string.dialog_delete_font)) },
- text = { Text(stringResource(R.string.dialog_delete_font_desc, fontName)) },
+ title = {
+ Text(
+ if (isSingleFont) {
+ stringResource(R.string.dialog_delete_font)
+ } else {
+ stringResource(R.string.dialog_delete_fonts)
+ }
+ )
+ },
+ text = {
+ Text(
+ if (isSingleFont) {
+ stringResource(R.string.dialog_delete_font_desc, fonts.first().displayName)
+ } else {
+ stringResource(R.string.dialog_delete_fonts_desc, fonts.size)
+ }
+ )
+ },
confirmButton = {
TextButton(
onClick = onConfirm,
@@ -498,3 +587,7 @@ fun DeleteFontConfirmationDialog(
}
)
}
+
+private fun Set.toggle(id: String): Set {
+ return if (id in this) this - id else this + id
+}
diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt
index 926488a..5dce10d 100644
--- a/app/src/main/java/com/aryan/reader/HomeScreen.kt
+++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt
@@ -130,6 +130,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -436,7 +437,7 @@ fun HomeScreen(
onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled,
- hasSyncedFolder = uiState.syncedFolders.isNotEmpty(),
+ hasSyncedFolder = uiState.syncedFolders.any { it.localSyncEnabled },
usePdfFileNameAsDisplayName = uiState.usePdfFileNameAsDisplayName
)
}
@@ -767,7 +768,8 @@ private fun RecentFilesGrid(
modifier = Modifier.size(16.dp)
)
}
- }
+ },
+ modifier = Modifier.testTag("HomeTab_${tab.bookId}")
)
}
}
@@ -817,6 +819,7 @@ fun RecentFileCard(
androidx.compose.material3.ElevatedCard(
modifier = modifier
+ .testTag("HomeRecentFileCard_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@@ -1485,7 +1488,7 @@ private fun AppDrawerContent(
Spacer(modifier = Modifier.weight(1f))
// legal links
- if (uiState.currentUser != null && !isOss) {
+ if (uiState.currentUser != null || (isOss && !BuildConfig.IS_OFFLINE)) {
val uriHandler = LocalUriHandler.current
val baseStyle = MaterialTheme.typography.labelMedium
var scaledTextStyle by remember { mutableStateOf(baseStyle) }
diff --git a/app/src/main/java/com/aryan/reader/LibraryModels.kt b/app/src/main/java/com/aryan/reader/LibraryModels.kt
index 6e5c3f1..690462e 100644
--- a/app/src/main/java/com/aryan/reader/LibraryModels.kt
+++ b/app/src/main/java/com/aryan/reader/LibraryModels.kt
@@ -16,6 +16,7 @@ typealias ShelfType = com.aryan.reader.shared.ShelfType
internal val ANDROID_READABLE_FILE_TYPES = SharedFileCapabilities.readableTypesFor(ReaderPlatform.ANDROID)
internal val ANDROID_SYNCABLE_FILE_TYPES = SharedFileCapabilities.syncableTypesFor(ReaderPlatform.ANDROID)
+internal val COMIC_ARCHIVE_FILE_TYPES = SharedFileCapabilities.comicArchiveTypes
internal val PDF_VIEWER_FILE_TYPES = com.aryan.reader.shared.PDF_VIEWER_FILE_TYPES
internal val EPUB_READER_FILE_TYPES = com.aryan.reader.shared.EPUB_READER_FILE_TYPES
diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
index 90370a0..ae267c1 100644
--- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt
+++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
@@ -105,6 +105,7 @@ import androidx.compose.material3.TextButton
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -120,6 +121,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
@@ -135,15 +137,19 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.media3.common.util.UnstableApi
import androidx.navigation.NavHostController
+import coil.ImageLoader
import coil.compose.AsyncImage
-import coil.request.ImageRequest
+import coil.decode.SvgDecoder
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.TagEntity
import com.aryan.reader.opds.OpdsAcquisition
import com.aryan.reader.opds.OpdsCatalog
import com.aryan.reader.opds.OpdsDownloadState
import com.aryan.reader.opds.OpdsEntry
+import com.aryan.reader.opds.OpdsRepository
import com.aryan.reader.opds.OpdsViewModel
+import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
+import com.aryan.reader.shared.opds.SharedOpdsLocalBookMatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -325,6 +331,7 @@ fun LibraryScreen(
onEditFolderFiltersClick = { folder, filters -> viewModel.updateFolderFilters(folder, filters) },
syncedFolders = uiState.syncedFolders,
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
+ onFolderLocalSyncChange = viewModel::setFolderLocalSyncEnabled,
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
downloadingBookIds = uiState.downloadingBookIds,
lastFolderScanTime = uiState.lastFolderScanTime,
@@ -590,6 +597,7 @@ fun LibraryScreenContent(
isRefreshing: Boolean,
syncedFolders: List,
onRemoveFolderClick: (SyncedFolder) -> Unit,
+ onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onOpdsBookDownloaded: (Uri, String) -> Unit,
onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
onDeleteCatalogStreams: (String) -> Unit,
@@ -666,7 +674,8 @@ fun LibraryScreenContent(
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
- .focusRequester(searchFocusRequester),
+ .focusRequester(searchFocusRequester)
+ .testTag("LibrarySearchTextField"),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@@ -693,7 +702,10 @@ fun LibraryScreenContent(
Icon(Icons.Default.FilterList, contentDescription = stringResource(R.string.content_desc_filter))
}
Box {
- TextButton(onClick = { showSortMenu = true }) {
+ TextButton(
+ onClick = { showSortMenu = true },
+ modifier = Modifier.testTag("LibrarySortButton")
+ ) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
@@ -822,7 +834,9 @@ fun LibraryScreenContent(
text = { Text(stringResource(R.string.fab_new_shelf)) },
icon = { Icon(Icons.Default.Add, contentDescription = stringResource(R.string.fab_new_shelf)) },
onClick = onNewShelfClick,
- modifier = Modifier.padding(16.dp)
+ modifier = Modifier
+ .padding(16.dp)
+ .testTag("LibraryNewShelfFab")
)
}
}
@@ -888,6 +902,7 @@ fun LibraryScreenContent(
allRecentFiles = rawLibraryFiles,
onAddFolderClick = onSelectSyncFolderClick,
onRemoveFolderClick = onRemoveFolderClick,
+ onFolderLocalSyncChange = onFolderLocalSyncChange,
onEditFolderFiltersClick = onEditFolderFiltersClick,
onScanNowClick = onScanNowClick,
onSyncMetadataClick = onSyncMetadataClick,
@@ -1140,7 +1155,8 @@ private fun ShelfDetailScreen(
modifier = Modifier
.weight(1f)
.padding(vertical = 4.dp)
- .focusRequester(searchFocusRequester),
+ .focusRequester(searchFocusRequester)
+ .testTag("ShelfSearchTextField"),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
@@ -1193,7 +1209,10 @@ private fun ShelfDetailScreen(
},
actions = {
Box {
- TextButton(onClick = { showSortMenu = true }) {
+ TextButton(
+ onClick = { showSortMenu = true },
+ modifier = Modifier.testTag("ShelfSortButton")
+ ) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
@@ -1375,7 +1394,10 @@ private fun AddBooksModeScreen(
},
actions = {
Box {
- TextButton(onClick = { showSortMenu = true }) {
+ TextButton(
+ onClick = { showSortMenu = true },
+ modifier = Modifier.testTag("AddBooksSortButton")
+ ) {
Icon(
painter = painterResource(id = R.drawable.sort),
contentDescription = stringResource(R.string.content_desc_sort),
@@ -1581,6 +1603,7 @@ private fun ShelfListItem(
),
modifier = Modifier
.fillMaxWidth()
+ .testTag("ShelfItem_${shelf.id}")
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
else Modifier
@@ -1659,6 +1682,7 @@ private fun LibraryListItem(
),
modifier = Modifier
.fillMaxWidth()
+ .testTag("LibraryBookItem_${item.bookId}")
.graphicsLayer { alpha = if (item.isAvailable) 1.0f else 0.8f }
.then(
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large)
@@ -1929,12 +1953,15 @@ private fun FolderSyncScreen(
allRecentFiles: List,
onAddFolderClick: () -> Unit,
onRemoveFolderClick: (SyncedFolder) -> Unit,
+ onFolderLocalSyncChange: (SyncedFolder, Boolean, Boolean) -> Unit,
onEditFolderFiltersClick: (SyncedFolder, Set) -> Unit,
onScanNowClick: () -> Unit,
onSyncMetadataClick: () -> Unit,
isLoading: Boolean
) {
var editingFolder by remember { mutableStateOf(null) }
+ var disablingFolder by remember { mutableStateOf(null) }
+ val hasEnabledSyncFolders = syncedFolders.any { it.localSyncEnabled }
val folderStatsByUri = remember(allRecentFiles) {
allRecentFiles
.asSequence()
@@ -1973,7 +2000,7 @@ private fun FolderSyncScreen(
) {
FilledTonalButton(
onClick = onScanNowClick,
- enabled = !isLoading,
+ enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
@@ -1988,7 +2015,7 @@ private fun FolderSyncScreen(
androidx.compose.material3.OutlinedButton(
onClick = onSyncMetadataClick,
- enabled = !isLoading,
+ enabled = !isLoading && hasEnabledSyncFolders,
modifier = Modifier.weight(1f),
shape = MaterialTheme.shapes.small
) {
@@ -2016,6 +2043,13 @@ private fun FolderSyncScreen(
folder = folder,
stats = folderStatsByUri[folder.uriString] ?: FolderFileStats.Empty,
onRemoveClick = onRemoveFolderClick,
+ onLocalSyncToggleClick = { selectedFolder ->
+ if (selectedFolder.localSyncEnabled) {
+ disablingFolder = selectedFolder
+ } else {
+ onFolderLocalSyncChange(selectedFolder, true, false)
+ }
+ },
onEditFiltersClick = { editingFolder = folder }
)
}
@@ -2033,6 +2067,46 @@ private fun FolderSyncScreen(
onDismiss = { editingFolder = null }
)
}
+
+ disablingFolder?.let { folder ->
+ AlertDialog(
+ onDismissRequest = { disablingFolder = null },
+ title = { Text(stringResource(R.string.dialog_disable_folder_local_sync_title)) },
+ text = {
+ Text(
+ stringResource(
+ R.string.dialog_disable_folder_local_sync_desc,
+ LOCAL_FOLDER_SYNC_DATA_DIR
+ )
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onFolderLocalSyncChange(folder, false, true)
+ disablingFolder = null
+ }
+ ) {
+ Text(stringResource(R.string.action_disable_remove_sync_data))
+ }
+ },
+ dismissButton = {
+ Row {
+ TextButton(onClick = { disablingFolder = null }) {
+ Text(stringResource(R.string.action_cancel))
+ }
+ TextButton(
+ onClick = {
+ onFolderLocalSyncChange(folder, false, false)
+ disablingFolder = null
+ }
+ ) {
+ Text(stringResource(R.string.action_disable_keep_sync_data))
+ }
+ }
+ }
+ )
+ }
}
private data class FolderFileStats(
@@ -2050,6 +2124,7 @@ private fun FolderCard(
folder: SyncedFolder,
stats: FolderFileStats,
onRemoveClick: (SyncedFolder) -> Unit,
+ onLocalSyncToggleClick: (SyncedFolder) -> Unit,
onEditFiltersClick: (SyncedFolder) -> Unit
) {
var showMenu by remember { mutableStateOf(false) }
@@ -2075,13 +2150,22 @@ private fun FolderCard(
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(12.dp))
- Text(
- text = folder.name,
- style = MaterialTheme.typography.titleMedium,
- fontWeight = FontWeight.Bold,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis
- )
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = folder.name,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (!folder.localSyncEnabled) {
+ Text(
+ text = stringResource(R.string.folder_local_sync_disabled),
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
}
Box {
@@ -2096,6 +2180,21 @@ private fun FolderCard(
onEditFiltersClick(folder)
}
)
+ DropdownMenuItem(
+ text = {
+ Text(
+ if (folder.localSyncEnabled) {
+ stringResource(R.string.menu_disable_folder_local_sync)
+ } else {
+ stringResource(R.string.menu_enable_folder_local_sync)
+ }
+ )
+ },
+ onClick = {
+ showMenu = false
+ onLocalSyncToggleClick(folder)
+ }
+ )
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_remove_folder)) },
onClick = {
@@ -2377,6 +2476,7 @@ fun OpdsTab(
val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
val downloadingState = uiState.downloadingState
val context = LocalContext.current
+ val coverImageLoader = rememberOpdsCoverImageLoader(uiState.currentCatalog)
var selectedEntry by remember { mutableStateOf(null) }
var showCatalogDialog by remember { mutableStateOf(false) }
var editingCatalog by remember { mutableStateOf(null) }
@@ -2601,6 +2701,7 @@ fun OpdsTab(
entry = entry,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[entry.id],
+ coverImageLoader = coverImageLoader,
onDownloadClick = { acquisition ->
opdsViewModel.downloadBook(
entry, acquisition, context
@@ -2649,6 +2750,7 @@ fun OpdsTab(
entry = selectedEntry!!,
localLibraryFiles = localLibraryFiles,
downloadState = downloadingState[selectedEntry!!.id],
+ coverImageLoader = coverImageLoader,
onDownloadFormat = { acquisition ->
opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
onBookDownloaded(downloadedUri, selectedEntry!!.title)
@@ -2776,6 +2878,29 @@ fun OpdsTab(
}
}
+@Composable
+private fun rememberOpdsCoverImageLoader(catalog: OpdsCatalog?): ImageLoader {
+ val context = LocalContext.current.applicationContext
+ val username = catalog?.username
+ val password = catalog?.password
+ val imageLoader = remember(context, username, password) {
+ ImageLoader.Builder(context)
+ .okHttpClient {
+ OpdsRepository.sharedHttpClient.newBuilder()
+ .authenticator(OpdsRepository.OpdsAuthenticator(username, password))
+ .build()
+ }
+ .components {
+ add(SvgDecoder.Factory())
+ }
+ .build()
+ }
+ DisposableEffect(imageLoader) {
+ onDispose { imageLoader.shutdown() }
+ }
+ return imageLoader
+}
+
@Composable
fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
Surface(
@@ -2853,13 +2978,20 @@ fun OpdsBookCard(
entry: OpdsEntry,
localLibraryFiles: List,
downloadState: OpdsDownloadState?,
+ coverImageLoader: ImageLoader,
onDownloadClick: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
onClick: () -> Unit
) {
val libraryItem = remember(entry, localLibraryFiles) {
- localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
+ SharedOpdsLocalBookMatcher.find(
+ entry = entry,
+ books = localLibraryFiles,
+ title = { it.title },
+ displayName = { it.displayName },
+ path = { it.uriString }
+ )
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
@@ -2878,6 +3010,7 @@ fun OpdsBookCard(
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
+ imageLoader = coverImageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 70.dp, height = 100.dp)
@@ -2984,6 +3117,7 @@ fun OpdsBookDetailsSheet(
entry: OpdsEntry,
localLibraryFiles: List,
downloadState: OpdsDownloadState?,
+ coverImageLoader: ImageLoader,
onDownloadFormat: (OpdsAcquisition) -> Unit,
onReadClick: (RecentFileItem) -> Unit,
onStreamClick: () -> Unit,
@@ -2992,7 +3126,13 @@ fun OpdsBookDetailsSheet(
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val libraryItem = remember(entry, localLibraryFiles) {
- localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
+ SharedOpdsLocalBookMatcher.find(
+ entry = entry,
+ books = localLibraryFiles,
+ title = { it.title },
+ displayName = { it.displayName },
+ path = { it.uriString }
+ )
}
val isDownloading = downloadState?.isDownloading == true
val progress = downloadState?.progress
@@ -3012,6 +3152,7 @@ fun OpdsBookDetailsSheet(
AsyncImage(
model = entry.coverUrl,
contentDescription = null,
+ imageLoader = coverImageLoader,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(width = 110.dp, height = 160.dp)
diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt
index 654ec84..4a7b4bd 100644
--- a/app/src/main/java/com/aryan/reader/MainViewModel.kt
+++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt
@@ -62,12 +62,15 @@ import com.aryan.reader.data.FirestoreRepository
import com.aryan.reader.data.FontMetadata
import com.aryan.reader.data.FontsRepository
import com.aryan.reader.data.GoogleDriveRepository
+import com.aryan.reader.data.LocalSyncUtils
import com.aryan.reader.data.PurchaseEntity
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.data.RemoteConfigRepository
import com.aryan.reader.data.ShelfMetadata
import com.aryan.reader.data.TagEntity
+import com.aryan.reader.data.effectiveAnnotationModifiedTimestamp
+import com.aryan.reader.data.effectiveReadingPositionModifiedTimestamp
import com.aryan.reader.data.getUri
import com.aryan.reader.data.toBookMetadata
import com.aryan.reader.data.toRecentFileItem
@@ -109,6 +112,11 @@ import com.aryan.reader.shared.SharedLibraryEditor
import com.aryan.reader.shared.SharedImportOutcomeCounts
import com.aryan.reader.shared.SharedImportPlanner
import com.aryan.reader.shared.pdf.SharedPdfAnnotationSidecarCodec
+import com.aryan.reader.shared.shouldApplyRemoteCloudBookMetadataUpdate
+import com.aryan.reader.shared.shouldDownloadRemoteCloudBookContent
+import com.aryan.reader.shared.shouldUploadLocalCloudBookContent
+import com.aryan.reader.shared.shouldUploadLocalCloudBookMetadataUpdate
+import com.aryan.reader.shared.sharedCloudBookContentFileName
import com.aryan.reader.shared.AppAction as SharedAppAction
import com.aryan.reader.shared.LibraryAction as SharedLibraryAction
import kotlinx.coroutines.CompletableDeferred
@@ -132,7 +140,6 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -163,7 +170,14 @@ private data class CachedSpeechBubble(
val maskBitmap: Bitmap?
)
+private data class PendingExternalFileRemoval(
+ val bookId: String,
+ val uriString: String?
+)
+
private const val BANNER_AUTO_DISMISS_MILLIS = 3_000L
+private const val CLOUD_CONTENT_RETRY_DELAY_MILLIS = 10_000L
+private const val CLOUD_METADATA_UPLOAD_DEBOUNCE_MILLIS = 1_500L
@kotlin.OptIn(ExperimentalSerializationApi::class)
@UnstableApi
@@ -212,6 +226,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private var bannerDismissGeneration = 0L
private var pendingSwitchDeferred: CompletableDeferred? = null
private var externalOpenedBookId: String? = null
+ private var cloudContentRetryJob: Job? = null
+ private val cloudMetadataUploadJobs = ConcurrentHashMap()
private var panelDetector: com.aryan.reader.ml.IPanelDetector? = null
private var speechBubbleDetector: ISpeechBubbleDetector? = null
@@ -595,8 +611,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val existingItem = recentFilesRepository.getFileByBookId(hash)
if (existingItem != null) {
- Timber.i("Book with ID: $hash already exists. Skipping import.")
- return null
+ val pendingRemoval = pendingExternalFileRemovals()
+ .firstOrNull { it.bookId == hash }
+ if (pendingRemoval != null) {
+ deletePendingExternalFileRemoval(
+ pendingRemoval.copy(uriString = pendingRemoval.uriString ?: existingItem.uriString)
+ )
+ } else {
+ Timber.i("Book with ID: $hash already exists. Skipping import.")
+ return null
+ }
}
val fileName = displayName ?: ""
@@ -1191,11 +1215,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- if (_internalState.value.syncedFolders.isNotEmpty()) {
+ if (_internalState.value.syncedFolders.any { it.localSyncEnabled }) {
triggerFolderSyncWorker(metadataOnly = false, showFeedback = false)
}
sweepOrphanedCache()
+ cleanupPendingExternalFileRemovals()
restoreReaderSessionIfNeeded()
viewModelScope.launch { billingClientWrapper.initializeConnection() }
@@ -1225,6 +1250,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (_internalState.value.isSyncEnabled) {
viewModelScope.launch {
+ logCloudSyncTrace {
+ "android.startup.sync_check user=${newUserData.uid} isSyncEnabled=${_internalState.value.isSyncEnabled}"
+ }
Timber.tag("AnnotationSync").d(
"Startup: Pro user & Sync enabled. Initiating cloud sync."
)
@@ -1232,6 +1260,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (googleDriveRepository.hasDrivePermissions(appContext)) {
syncWithCloud(showBanner = false)
} else {
+ logCloudSyncTrace { "android.startup.sync_skip reason=missing_drive_permissions user=${newUserData.uid}" }
Timber.tag("AnnotationSync").d(
"Startup: Sync skipped. Missing Drive permissions."
)
@@ -1276,6 +1305,119 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ private fun pendingExternalFileRemovals(): List {
+ return prefs.getStringSet(KEY_PENDING_EXTERNAL_FILE_REMOVALS, emptySet())
+ .orEmpty()
+ .mapNotNull(::decodePendingExternalFileRemoval)
+ .distinctBy { it.bookId }
+ }
+
+ private fun markPendingExternalFileRemoval(bookId: String, uriString: String?) {
+ if (bookId.isBlank()) return
+ val removalsByBookId = pendingExternalFileRemovals()
+ .associateBy { it.bookId }
+ .toMutableMap()
+ removalsByBookId[bookId] = PendingExternalFileRemoval(bookId, uriString)
+ writePendingExternalFileRemovals(removalsByBookId.values)
+ }
+
+ private fun clearPendingExternalFileRemovals(bookIds: Set) {
+ if (bookIds.isEmpty()) return
+ val remaining = pendingExternalFileRemovals().filterNot { it.bookId in bookIds }
+ writePendingExternalFileRemovals(remaining)
+ }
+
+ private fun writePendingExternalFileRemovals(removals: Collection) {
+ val encoded = removals
+ .filter { it.bookId.isNotBlank() }
+ .mapTo(mutableSetOf(), ::encodePendingExternalFileRemoval)
+ prefs.edit(commit = true) {
+ if (encoded.isEmpty()) {
+ remove(KEY_PENDING_EXTERNAL_FILE_REMOVALS)
+ } else {
+ putStringSet(KEY_PENDING_EXTERNAL_FILE_REMOVALS, encoded)
+ }
+ }
+ }
+
+ private fun cleanupPendingExternalFileRemovals() {
+ val removals = pendingExternalFileRemovals()
+ if (removals.isEmpty()) return
+
+ val pendingBookIds = removals.mapTo(mutableSetOf()) { it.bookId }
+ if (prefs.getString(KEY_LAST_OPEN_BOOK_ID, null) in pendingBookIds) {
+ clearPersistedReaderSession()
+ }
+
+ viewModelScope.launch {
+ removals.forEach { removal ->
+ deletePendingExternalFileRemoval(removal)
+ }
+ }
+ }
+
+ private fun deletePendingExternalFileRemoval(bookId: String, uriString: String?) {
+ markPendingExternalFileRemoval(bookId, uriString)
+ viewModelScope.launch {
+ deletePendingExternalFileRemoval(PendingExternalFileRemoval(bookId, uriString))
+ }
+ }
+
+ private suspend fun deletePendingExternalFileRemoval(removal: PendingExternalFileRemoval) {
+ var shouldRetry = false
+ runCatching {
+ cleanupBookDataLocally(removal.bookId)
+ }.onFailure { error ->
+ Timber.w(error, "Failed to clear local caches for pending external file ${removal.bookId}")
+ }
+
+ runCatching {
+ recentFilesRepository.deleteFilePermanently(listOf(removal.bookId))
+ }.onFailure { error ->
+ shouldRetry = true
+ Timber.w(error, "Failed to remove pending external file ${removal.bookId} from library")
+ }
+
+ removal.uriString?.let { uriString ->
+ runCatching {
+ bookImporter.deleteBookByUriString(uriString)
+ }.onFailure { error ->
+ shouldRetry = true
+ Timber.w(error, "Failed to delete pending external file copy for ${removal.bookId}")
+ }
+ }
+
+ if (!shouldRetry) {
+ clearPendingExternalFileRemovals(setOf(removal.bookId))
+ }
+ }
+
+ private fun encodePendingExternalFileRemoval(removal: PendingExternalFileRemoval): String {
+ return JSONObject()
+ .put("bookId", removal.bookId)
+ .apply {
+ if (!removal.uriString.isNullOrBlank()) {
+ put("uriString", removal.uriString)
+ }
+ }
+ .toString()
+ }
+
+ private fun decodePendingExternalFileRemoval(value: String): PendingExternalFileRemoval? {
+ val trimmed = value.trim()
+ if (trimmed.isBlank()) return null
+ return if (trimmed.startsWith("{")) {
+ runCatching {
+ val json = JSONObject(trimmed)
+ val bookId = json.optString("bookId").takeIf { it.isNotBlank() }
+ val uriString = json.optString("uriString").takeIf { it.isNotBlank() }
+ bookId?.let { PendingExternalFileRemoval(it, uriString) }
+ }.getOrNull()
+ } else {
+ PendingExternalFileRemoval(trimmed, null)
+ }
+ }
+
private fun restoreReaderSessionIfNeeded() {
val currentState = _internalState.value
if (currentState.selectedBookId != null || currentState.selectedPdfUri != null || currentState.selectedEpubUri != null) {
@@ -1286,6 +1428,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
runCatching { FileType.valueOf(typeName) }.getOrNull()
}
val restoreBookId = prefs.getString(KEY_LAST_OPEN_BOOK_ID, null) ?: return
+ if (restoreBookId in pendingExternalFileRemovals().map { it.bookId }) {
+ clearPersistedReaderSession()
+ return
+ }
if (persistedType == null) {
clearPersistedReaderSession()
return
@@ -1614,17 +1760,27 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun importFont(uri: Uri) {
+ importFonts(listOf(uri))
+ }
+
+ fun importFonts(uris: List) {
+ if (uris.isEmpty()) return
viewModelScope.launch {
_internalState.update { it.copy(isLoading = true) }
- val result = fontsRepository.importFont(uri)
- result.onSuccess { font ->
- if (uiState.value.isSyncEnabled) {
- uploadNewFont(font)
+ try {
+ uris.forEach { uri ->
+ val result = fontsRepository.importFont(uri)
+ result.onSuccess { font ->
+ if (uiState.value.isSyncEnabled) {
+ uploadNewFont(font)
+ }
+ }.onFailure {
+ showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true)
+ }
}
- }.onFailure {
- showBanner(appContext.getString(R.string.error_import_font, it.message), isError = true)
+ } finally {
+ _internalState.update { it.copy(isLoading = false) }
}
- _internalState.update { it.copy(isLoading = false) }
}
}
@@ -1650,9 +1806,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
fun deleteFont(fontId: String) {
+ deleteFonts(listOf(fontId))
+ }
+
+ fun deleteFonts(fontIds: Collection) {
+ val uniqueFontIds = fontIds.filter { it.isNotBlank() }.toSet()
+ if (uniqueFontIds.isEmpty()) return
viewModelScope.launch {
- fontsRepository.deleteFont(fontId)
- if (_internalState.value.appFontPreference.referencesCustomFont(fontId)) {
+ uniqueFontIds.forEach { fontId ->
+ fontsRepository.deleteFont(fontId)
+ }
+ if (uniqueFontIds.any { _internalState.value.appFontPreference.referencesCustomFont(it) }) {
setAppFontPreference(AppFontPreference.System)
}
}
@@ -2042,48 +2206,217 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private fun uploadSingleBookMetadata(book: RecentFileItem) {
+ private fun queueCloudMetadataUpload(bookId: String, reason: String, debounce: Boolean = true) {
if (!uiState.value.isSyncEnabled) return
+ cloudMetadataUploadJobs.remove(bookId)?.cancel()
+ val job = viewModelScope.launch {
+ if (debounce) delay(CLOUD_METADATA_UPLOAD_DEBOUNCE_MILLIS)
+ val latest = recentFilesRepository.getFileByBookId(bookId) ?: run {
+ logCloudSyncTrace { "android.upload.queue_skip reason=missing_local book=$bookId trigger=$reason" }
+ return@launch
+ }
+ logCloudSyncTrace {
+ "android.upload.queue_fire trigger=$reason debounce=$debounce ${latest.cloudSyncTraceSummary()}"
+ }
+ uploadSingleBookMetadata(latest)
+ }
+ cloudMetadataUploadJobs[bookId] = job
+ job.invokeOnCompletion {
+ if (cloudMetadataUploadJobs[bookId] == job) {
+ cloudMetadataUploadJobs.remove(bookId)
+ }
+ }
+ }
+
+ fun queuePdfSidecarCloudUpload(bookId: String) {
+ queueCloudMetadataUpload(bookId, reason = "pdf_sidecar")
+ }
+
+ private fun uploadSingleBookMetadata(book: RecentFileItem) {
+ if (!uiState.value.isSyncEnabled) {
+ logCloudSyncTrace { "android.upload.skip reason=sync_disabled ${book.cloudSyncTraceSummary()}" }
+ return
+ }
if (book.uriString?.startsWith("opds-pse") == true) {
+ logCloudSyncTrace { "android.upload.skip reason=opds_stream ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping metadata sync for OPDS stream book: ${book.displayName}")
return
}
if (book.sourceFolderUri != null) {
+ logCloudSyncTrace { "android.upload.skip reason=folder_book ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
return
}
if (book.isManualOnlyReaderFile()) {
+ logCloudSyncTrace { "android.upload.skip reason=manual_only ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping metadata sync for manual-only reader file: ${book.displayName}")
return
}
- val currentUser = uiState.value.currentUser ?: return
+ val currentUser = uiState.value.currentUser ?: run {
+ logCloudSyncTrace { "android.upload.skip reason=no_user ${book.cloudSyncTraceSummary()}" }
+ return
+ }
viewModelScope.launch {
try {
val deviceId = getInstallationId()
+ var bookForMetadata = book
+ var uploadedAnnotationPayload = false
+ var uploadedAnnotationModifiedTimestamp = 0L
+ var remoteBookLoaded = false
+ var remoteBookForUpload: BookMetadata? = null
+ var remoteAnnotationDriveTimestampLoaded = false
+ var remoteAnnotationDriveTimestamp = 0L
+ suspend fun loadRemoteBookForUpload(): BookMetadata? {
+ if (!remoteBookLoaded) {
+ remoteBookForUpload = firestoreRepository.getBookMetadata(currentUser.uid, book.bookId)
+ remoteBookLoaded = true
+ }
+ return remoteBookForUpload
+ }
+ suspend fun loadRemoteAnnotationDriveTimestamp(): Long {
+ if (!remoteAnnotationDriveTimestampLoaded) {
+ val remote = loadRemoteBookForUpload()
+ remoteAnnotationDriveTimestamp = if (remote?.hasAnnotations == true) {
+ googleDriveRepository.getAccessToken(appContext)?.let { accessToken ->
+ googleDriveRepository.getFiles(accessToken)
+ ?.files
+ .orEmpty()
+ .firstOrNull { it.name == cloudPdfAnnotationDriveFileName(book.bookId) }
+ ?.modifiedTimeMillis
+ } ?: 0L
+ } else {
+ 0L
+ }
+ remoteAnnotationDriveTimestampLoaded = true
+ }
+ return remoteAnnotationDriveTimestamp
+ }
+ if (book.needsRemoteEpubAnnotationMetadataGuard()) {
+ val remote = loadRemoteBookForUpload()
+ val merged = bookForMetadata.mergeRemoteEpubAnnotationMetadata(remote)
+ if (merged != bookForMetadata) {
+ logCloudSyncTrace {
+ "android.upload.epub_annotation_preserve book=${book.bookId} " +
+ "local=${bookForMetadata.cloudSyncTraceSummary()} " +
+ "remote=${remote?.cloudSyncTraceSummary() ?: "null"} " +
+ "merged=${merged.cloudSyncTraceSummary()}"
+ }
+ recentFilesRepository.addRecentFile(merged)
+ bookForMetadata = merged
+ }
+ }
+ val remoteForContent = loadRemoteBookForUpload()?.toRecentFileItem()
+ if (shouldUploadLocalBookContent(bookForMetadata, remoteForContent)) {
+ val accessToken = googleDriveRepository.getAccessToken(appContext) ?: run {
+ logCloudSyncTrace { "android.upload.content_guard_skip reason=no_access_token ${bookForMetadata.cloudSyncTraceSummary()}" }
+ return@launch
+ }
+ val source = bookForMetadata.getUri()?.path?.let(::File)
+ if (source?.exists() != true) {
+ logCloudSyncTrace {
+ "android.upload.content_guard_skip reason=file_missing book=${bookForMetadata.bookId} " +
+ "path=${(source?.absolutePath).cloudSyncPreview()}"
+ }
+ return@launch
+ }
+ logCloudSyncTrace {
+ "android.upload.content_guard_start book=${bookForMetadata.bookId} " +
+ "localContentTs=${bookForMetadata.fileContentModifiedTimestamp} " +
+ "remoteContentTs=${remoteForContent?.fileContentModifiedTimestamp ?: 0L}"
+ }
+ val uploadedFile = googleDriveRepository.uploadFile(
+ accessToken,
+ bookForMetadata.bookId,
+ source,
+ bookForMetadata.type
+ )
+ if (uploadedFile == null) {
+ logCloudSyncTrace { "android.upload.content_guard_failed book=${bookForMetadata.bookId}" }
+ return@launch
+ }
+ val contentTimestamp = bookForMetadata.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: source.lastModified()
+ bookForMetadata = bookForMetadata.copy(
+ fileSize = source.length(),
+ fileContentModifiedTimestamp = contentTimestamp
+ )
+ logCloudSyncTrace {
+ "android.upload.content_guard_success book=${bookForMetadata.bookId} " +
+ "driveId=${uploadedFile.id} contentTs=$contentTimestamp"
+ }
+ }
+ logCloudSyncTrace { "android.upload.start device=$deviceId ${bookForMetadata.cloudSyncTraceSummary()}" }
Timber.tag("AnnotationSync").d("Preparing to sync book: ${book.bookId}")
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(book.bookId)
+ val deletedInkFile = pdfAnnotationRepository.getDeletedAnnotationsFileForSync(book.bookId)
val richTextFile = pdfRichTextRepository.getFileForSync(book.bookId)
val layoutFile = pageLayoutRepository.getLayoutFile(book.bookId)
val textBoxFile = pdfTextBoxRepository.getFileForSync(book.bookId)
val highlightFile = pdfHighlightRepository.getFileForSync(book.bookId)
- val hasInk = inkFile?.exists() == true
- val hasRichText = richTextFile.exists()
+ val hasInk = inkFile.hasSyncableCloudAnnotationPayload()
+ val hasDeletedInk = deletedInkFile.hasSyncableCloudAnnotationPayload()
+ val hasRichText = richTextFile.hasSyncableCloudAnnotationPayload()
val hasLayout = layoutFile.exists()
- val hasTextBoxes = textBoxFile.exists()
- val hasHighlights = highlightFile.exists()
- val hasAnyData = hasInk || hasRichText || hasLayout || hasTextBoxes || hasHighlights
+ val hasTextBoxes = textBoxFile.hasSyncableCloudAnnotationPayload()
+ val hasHighlights = highlightFile.hasSyncableCloudAnnotationPayload()
+ val sidecars = AndroidPdfCloudSidecarState(
+ hasInk = hasInk,
+ inkTimestamp = inkFile?.lastModified() ?: 0L,
+ hasDeletedInk = hasDeletedInk,
+ deletedInkTimestamp = deletedInkFile?.lastModified() ?: 0L,
+ hasRichText = hasRichText,
+ richTextTimestamp = richTextFile.lastModified(),
+ hasLayout = hasLayout,
+ layoutTimestamp = layoutFile.lastModified(),
+ hasTextBoxes = hasTextBoxes,
+ textBoxesTimestamp = textBoxFile.lastModified(),
+ hasHighlights = hasHighlights,
+ highlightsTimestamp = highlightFile.lastModified()
+ )
+ logCloudSyncTrace {
+ "android.upload.sidecars book=${book.bookId} hasInk=$hasInk hasDeletedInk=$hasDeletedInk hasText=$hasRichText " +
+ "hasLayout=$hasLayout hasTextBoxes=$hasTextBoxes hasHighlights=$hasHighlights " +
+ "payloadTs=${sidecars.annotationPayloadTimestamp} bundleTs=${sidecars.bundleTimestamp}"
+ }
+ logCloudAnnotationSyncTrace {
+ "android.upload.inspect book=${book.bookId} remoteHas=${remoteBookForUpload?.hasAnnotations} " +
+ "remoteTs=${remoteBookForUpload?.lastModifiedTimestamp ?: 0L} " +
+ "ink{exists=$hasInk bytes=${inkFile?.length() ?: 0L} ts=${sidecars.inkTimestamp}} " +
+ "deletedInk{exists=$hasDeletedInk bytes=${deletedInkFile?.length() ?: 0L} ts=${sidecars.deletedInkTimestamp}} " +
+ "text{exists=$hasRichText bytes=${richTextFile.length()} ts=${sidecars.richTextTimestamp}} " +
+ "layout{exists=$hasLayout bytes=${layoutFile.length()} ts=${sidecars.layoutTimestamp}} " +
+ "textBoxes{exists=$hasTextBoxes bytes=${textBoxFile.length()} ts=${sidecars.textBoxesTimestamp}} " +
+ "highlights{exists=$hasHighlights bytes=${highlightFile.length()} ts=${sidecars.highlightsTimestamp}} " +
+ "payloadTs=${sidecars.annotationPayloadTimestamp} bundleTs=${sidecars.bundleTimestamp} " +
+ "hasPayload=${sidecars.hasAnnotationPayload}"
+ }
+ val remoteAnnotationDriveTimestampForUpload = loadRemoteAnnotationDriveTimestamp()
+ val remoteAnnotationTimestampForUpload =
+ remoteBookForUpload?.effectiveAnnotationModifiedTimestamp(remoteAnnotationDriveTimestampForUpload) ?: 0L
+ val localAnnotationsShouldUpload = shouldUploadLocalPdfCloudAnnotations(
+ localSidecars = sidecars,
+ remoteHasAnnotations = remoteBookForUpload?.hasAnnotations == true,
+ remoteAnnotationModifiedTimestamp = remoteAnnotationTimestampForUpload
+ )
+ logCloudAnnotationSyncTrace {
+ "android.upload.annotation_decision book=${book.bookId} " +
+ "localShouldUpload=$localAnnotationsShouldUpload remoteHas=${remoteBookForUpload?.hasAnnotations} " +
+ "remoteAnnTs=$remoteAnnotationTimestampForUpload " +
+ "remoteDriveAnnTs=$remoteAnnotationDriveTimestampForUpload payloadTs=${sidecars.annotationPayloadTimestamp}"
+ }
Timber.d(
"android.cloud.export candidates book=${book.bookId} hasRichText=$hasRichText " +
- "richBytes=${if (hasRichText) richTextFile.length() else 0L} hasAnyData=$hasAnyData"
+ "richBytes=${if (hasRichText) richTextFile.length() else 0L} " +
+ "hasAnnotationPayload=${sidecars.hasAnnotationPayload}"
)
- if (hasAnyData) {
+ if (localAnnotationsShouldUpload) {
if (googleDriveRepository.hasDrivePermissions(appContext)) {
val accessToken = googleDriveRepository.getAccessToken(appContext)
@@ -2115,6 +2448,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
if (hasInk) putJsonSafe("ink", inkFile)
+ if (hasDeletedInk) putJsonSafe(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, deletedInkFile)
if (hasRichText) putJsonSafe("text", richTextFile)
if (hasLayout) putJsonSafe("layout", layoutFile)
if (hasTextBoxes) putJsonSafe("textBoxes", textBoxFile)
@@ -2123,7 +2457,54 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val bundleFile =
File(appContext.cacheDir, "sync_bundle_${book.bookId}.json")
val canonicalBundle = SharedPdfAnnotationSidecarCodec.canonicalizeDataJson(bundleJson.toString())
- bundleFile.writeText(canonicalBundle)
+ var uploadBundle = canonicalBundle
+ var mergedRemoteIntoUpload = false
+ if (remoteBookForUpload?.hasAnnotations == true) {
+ val remoteBundleFile = File(appContext.cacheDir, "remote_sync_bundle_${book.bookId}.json")
+ try {
+ val didDownloadRemote = googleDriveRepository.downloadAnnotationFile(
+ accessToken,
+ book.bookId,
+ remoteBundleFile
+ )
+ if (didDownloadRemote && remoteBundleFile.isFile) {
+ val remoteBundle = remoteBundleFile.readText()
+ val mergedBundle = SharedPdfAnnotationSidecarCodec.mergeAnnotationDataJson(
+ localDataJson = canonicalBundle,
+ remoteDataJson = remoteBundle,
+ preferRemoteOnConflict = false
+ )
+ val localCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(canonicalBundle)
+ val remoteCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(remoteBundle)
+ val mergedCount = SharedPdfAnnotationSidecarCodec.annotationCountFromDataJson(mergedBundle)
+ uploadBundle = mergedBundle
+ mergedRemoteIntoUpload = mergedBundle != canonicalBundle
+ logCloudAnnotationSyncTrace {
+ "android.upload.merge_remote book=${book.bookId} didDownload=true " +
+ "localCount=$localCount remoteCount=$remoteCount mergedCount=$mergedCount " +
+ "changed=$mergedRemoteIntoUpload"
+ }
+ } else {
+ logCloudAnnotationSyncTrace {
+ "android.upload.merge_remote_missing book=${book.bookId} " +
+ "didDownload=$didDownloadRemote tempExists=${remoteBundleFile.exists()}"
+ }
+ }
+ } catch (e: Exception) {
+ logCloudAnnotationSyncError(e) {
+ "android.upload.merge_remote_failed book=${book.bookId}"
+ }
+ } finally {
+ remoteBundleFile.delete()
+ }
+ }
+ bundleFile.writeText(uploadBundle)
+ logCloudAnnotationSyncTrace {
+ "android.upload.bundle_ready book=${book.bookId} " +
+ "rawKeys=${bundleJson.keys().asSequence().toList()} " +
+ "canonicalBytes=${canonicalBundle.length} uploadBytes=${uploadBundle.length} " +
+ "fileBytes=${bundleFile.length()}"
+ }
if (hasRichText) {
Timber.d(
"android.cloud.export.bundleReady book=${book.bookId} canonicalLen=${canonicalBundle.length} " +
@@ -2137,6 +2518,36 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
bundleFile.delete()
if (uploaded != null) {
+ uploadedAnnotationPayload = true
+ uploadedAnnotationModifiedTimestamp = uploaded.modifiedTimeMillis
+ if (mergedRemoteIntoUpload) {
+ recentFilesRepository.importAnnotationBundle(
+ book.bookId,
+ uploadBundle,
+ uploadedAnnotationModifiedTimestamp
+ )
+ logCloudAnnotationSyncTrace {
+ "android.upload.local_apply_merged book=${book.bookId} " +
+ "driveTs=$uploadedAnnotationModifiedTimestamp bytes=${uploadBundle.length}"
+ }
+ }
+ markPdfCloudAnnotationSidecarsSynced(
+ uploadedAnnotationModifiedTimestamp,
+ inkFile,
+ richTextFile,
+ layoutFile,
+ textBoxFile,
+ highlightFile,
+ deletedInkFile
+ )
+ logCloudAnnotationSyncTrace {
+ "android.upload.sidecar_success book=${book.bookId} driveId=${uploaded.id} " +
+ "driveTs=$uploadedAnnotationModifiedTimestamp bytes=${uploadBundle.length}"
+ }
+ logCloudSyncTrace {
+ "android.upload.sidecar_success book=${book.bookId} driveId=${uploaded.id} " +
+ "driveTs=$uploadedAnnotationModifiedTimestamp bytes=${uploadBundle.length}"
+ }
if (hasRichText) {
Timber
.d("android.cloud.export.uploadSuccess book=${book.bookId} driveId=${uploaded.id}")
@@ -2144,6 +2555,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("AnnotationSync")
.d("Bundle upload SUCCESS. ID: ${uploaded.id}")
} else {
+ logCloudAnnotationSyncTrace {
+ "android.upload.sidecar_failed book=${book.bookId} bytes=${uploadBundle.length}"
+ }
+ logCloudSyncTrace { "android.upload.sidecar_failed book=${book.bookId}; aborting_metadata_upload" }
if (hasRichText) {
Timber
.e("android.cloud.export.uploadFailed book=${book.bookId}")
@@ -2152,23 +2567,127 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
.e("Bundle upload FAILED. Skipping Firestore sync to prevent data loss.")
return@launch
}
+ } else {
+ logCloudAnnotationSyncTrace { "android.upload.skip_sidecar reason=no_access_token book=${book.bookId}" }
+ logCloudSyncTrace { "android.upload.sidecar_failed book=${book.bookId}; reason=no_access_token; aborting_metadata_upload" }
+ return@launch
}
+ } else {
+ logCloudAnnotationSyncTrace { "android.upload.skip_sidecar reason=missing_drive_permission book=${book.bookId}" }
+ logCloudSyncTrace { "android.upload.sidecar_failed book=${book.bookId}; reason=missing_drive_permission; aborting_metadata_upload" }
+ return@launch
}
} else {
- Timber.tag("AnnotationSync")
- .d("No local data (ink/text/layout) to upload for ${book.bookId}")
+ logCloudAnnotationSyncTrace {
+ "android.upload.skip_sidecar reason=${if (sidecars.hasAnnotationPayload) "remote_annotation_not_older" else "no_annotation_payload"} " +
+ "book=${book.bookId} layoutOnly=$hasLayout layoutTs=${sidecars.layoutTimestamp} " +
+ "remoteAnnTs=$remoteAnnotationTimestampForUpload payloadTs=${sidecars.annotationPayloadTimestamp}"
+ }
+ logCloudSyncTrace {
+ "android.upload.sidecars_skipped book=${book.bookId} " +
+ "reason=${if (sidecars.hasAnnotationPayload) "remote_annotation_not_older" else "no_annotation_payload"}"
+ }
+ Timber.tag("AnnotationSync").d(
+ if (sidecars.hasAnnotationPayload) {
+ "Local annotation payload is not newer than remote for ${book.bookId}"
+ } else {
+ "No local annotation payload (ink/text/text boxes/highlights) to upload for ${book.bookId}"
+ }
+ )
+ }
+
+ val latestLocalForMetadata = recentFilesRepository.getFileByBookId(bookForMetadata.bookId)
+ val refreshedBookForMetadata = bookForMetadata.withFreshLocalReadingPositionForCloudUpload(
+ latestLocalForMetadata
+ )
+ if (refreshedBookForMetadata != bookForMetadata) {
+ logCloudSyncTrace {
+ "android.upload.refresh_latest book=${bookForMetadata.bookId} " +
+ "before=${bookForMetadata.cloudSyncTraceSummary()} " +
+ "latest=${latestLocalForMetadata?.cloudSyncTraceSummary() ?: "null"} " +
+ "after=${refreshedBookForMetadata.cloudSyncTraceSummary()}"
+ }
+ bookForMetadata = refreshedBookForMetadata
+ }
+ val remoteMetadata = loadRemoteBookForUpload()
+ val localReadingTimestamp = bookForMetadata.effectiveReadingPositionModifiedTimestamp()
+ val remoteReadingTimestamp = remoteMetadata?.effectiveReadingPositionModifiedTimestamp() ?: 0L
+ val remoteAnnotationTimestamp = remoteMetadata?.effectiveAnnotationModifiedTimestamp(
+ remoteAnnotationDriveTimestampForUpload
+ ) ?: 0L
+ val remoteReadingPositionWins = remoteMetadata != null && remoteReadingTimestamp > localReadingTimestamp
+ val remoteMetadataWins = remoteMetadata != null &&
+ remoteMetadata.lastModifiedTimestamp > bookForMetadata.lastModifiedTimestamp
+ val metadataBase = if (remoteMetadataWins && remoteMetadata != null) {
+ remoteMetadata.toRecentFileItem().withLocalStorageForCloudMetadata(bookForMetadata)
+ } else {
+ bookForMetadata
+ }
+ val metadataBook = when {
+ remoteReadingPositionWins && remoteMetadata != null -> metadataBase.withCloudReadingPosition(remoteMetadata)
+ metadataBase != bookForMetadata -> metadataBase.withLocalReadingPosition(bookForMetadata)
+ else -> bookForMetadata
+ }
+ val readingPositionTimestamp = if (remoteReadingPositionWins) {
+ remoteReadingTimestamp
+ } else {
+ localReadingTimestamp
+ }
+ if (remoteReadingPositionWins) {
+ logCloudSyncTrace {
+ "android.upload.preserve_remote_position book=${bookForMetadata.bookId} " +
+ "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " +
+ "remote=${remoteMetadata?.cloudSyncTraceSummary() ?: "null"}"
+ }
+ }
+ if (remoteMetadataWins) {
+ logCloudSyncTrace {
+ "android.upload.preserve_remote_metadata book=${bookForMetadata.bookId} " +
+ "remoteTs=${remoteMetadata?.lastModifiedTimestamp ?: 0L} localTs=${bookForMetadata.lastModifiedTimestamp} " +
+ "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " +
+ metadataBook.cloudSyncTraceSummary("metadata")
+ }
}
val newTimestamp = System.currentTimeMillis()
- val metadataToSync = book.toBookMetadata().copy(
- lastModifiedTimestamp = newTimestamp, hasAnnotations = hasAnyData
+ val syncedAnnotationTimestamp = if (uploadedAnnotationPayload) {
+ uploadedAnnotationModifiedTimestamp.takeIf { it > 0L }
+ ?: maxOf(sidecars.annotationPayloadTimestamp, newTimestamp)
+ } else if (remoteMetadata?.hasAnnotations == true) {
+ remoteAnnotationTimestamp
+ } else {
+ 0L
+ }
+ val syncedHasAnnotations = uploadedAnnotationPayload || remoteMetadata?.hasAnnotations == true
+ val metadataToSync = metadataBook.toBookMetadata().copy(
+ lastModifiedTimestamp = newTimestamp,
+ readingPositionModifiedTimestamp = readingPositionTimestamp,
+ annotationModifiedTimestamp = syncedAnnotationTimestamp,
+ hasAnnotations = syncedHasAnnotations
)
firestoreRepository.syncBookMetadata(currentUser.uid, metadataToSync, deviceId)
- recentFilesRepository.addRecentFile(book.copy(lastModifiedTimestamp = newTimestamp))
+ recentFilesRepository.addRecentFile(
+ metadataBook.copy(
+ lastModifiedTimestamp = newTimestamp,
+ readingPositionModifiedTimestamp = readingPositionTimestamp
+ )
+ )
+ logCloudAnnotationSyncTrace {
+ "android.upload.metadata_success book=${bookForMetadata.bookId} newTs=$newTimestamp " +
+ "readTs=$readingPositionTimestamp hasAnnotations=$syncedHasAnnotations " +
+ "annTs=$syncedAnnotationTimestamp payloadTs=${sidecars.annotationPayloadTimestamp}"
+ }
+ logCloudSyncTrace {
+ "android.upload.metadata_success user=${currentUser.uid} oldTs=${bookForMetadata.lastModifiedTimestamp} " +
+ "newTs=$newTimestamp readTs=$readingPositionTimestamp annTs=$syncedAnnotationTimestamp " +
+ "remoteReadTs=$remoteReadingTimestamp localReadTs=$localReadingTimestamp " +
+ "hasAnnotations=$syncedHasAnnotations ${metadataBook.cloudSyncTraceSummary()}"
+ }
Timber.tag("AnnotationSync")
- .d("Firestore metadata updated for ${book.bookId} (hasData=$hasAnyData)")
+ .d("Firestore metadata updated for ${book.bookId} (hasAnnotationPayload=${sidecars.hasAnnotationPayload}, syncedHasAnnotations=$syncedHasAnnotations)")
} catch (e: Exception) {
+ logCloudSyncError(e) { "android.upload.failed ${book.cloudSyncTraceSummary()}" }
Timber.tag("AnnotationSync").e(e, "Failed to sync book data: ${book.bookId}")
}
}
@@ -2242,6 +2761,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val closingBookId = _internalState.value.selectedBookId
val uriString = _internalState.value.selectedPdfUri?.toString()
?: _internalState.value.selectedEpubUri?.toString()
+ logCloudSyncTrace {
+ "android.reader.close_request book=$closingBookId uri=${uriString.cloudSyncPreview()} sync=${uiState.value.isSyncEnabled}"
+ }
val ttsState = ttsController.ttsState.value
val isTtsActive = ttsState.playbackSource == "READER" &&
@@ -2274,26 +2796,34 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
clearPersistedReaderSession()
+ var removesExternalFileOnClose = false
if (closingBookId != null && closingBookId == externalOpenedBookId) {
val behavior = prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") ?: "ASK"
if (behavior == "ASK") {
_internalState.update { it.copy(showExternalFileSavePromptFor = closingBookId) }
} else if (behavior == "DELETE") {
- deleteBookPermanently(closingBookId)
+ removesExternalFileOnClose = true
+ deletePendingExternalFileRemoval(closingBookId, uriString)
+ } else {
+ clearPendingExternalFileRemovals(setOf(closingBookId))
}
externalOpenedBookId = null
}
- if (uriString != null) {
+ if (uriString != null && !removesExternalFileOnClose) {
viewModelScope.launch {
val freshBook = recentFilesRepository.getFileByUri(uriString)
freshBook?.let {
if (uiState.value.uploadingBookIds.contains(it.bookId)) {
+ logCloudSyncTrace { "android.reader.close_upload_skip reason=already_uploading ${it.cloudSyncTraceSummary()}" }
return@launch
}
if (uiState.value.isSyncEnabled) {
+ logCloudSyncTrace { "android.reader.close_upload_start ${it.cloudSyncTraceSummary()}" }
Timber.d("Book closed, triggering metadata sync for ${it.bookId}")
uploadSingleBookMetadata(it)
+ } else {
+ logCloudSyncTrace { "android.reader.close_upload_skip reason=sync_disabled ${it.cloudSyncTraceSummary()}" }
}
if (it.sourceFolderUri != null) {
@@ -2393,72 +2923,32 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun loadSyncedFoldersFromPrefs(): List {
- val jsonString = prefs.getString(KEY_SYNCED_FOLDERS_JSON, null)
- val folders = mutableListOf()
+ val jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null)
+ val oldUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null)
+ val folders = SyncedFolderPrefs.decodeSyncedFolders(
+ jsonString = jsonString,
+ legacyUri = oldUri,
+ legacyLastScanTime = prefs.getLong(SyncedFolderPrefs.KEY_LEGACY_LAST_FOLDER_SCAN_TIME, 0L),
+ legacyNameResolver = { uri -> getDisplayPathFromUri(appContext, uri) }
+ )
- if (jsonString == null && prefs.contains(KEY_SYNCED_FOLDER_URI)) {
- val oldUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null)
- val oldTime = prefs.getLong(KEY_LAST_FOLDER_SCAN_TIME, 0L)
- if (oldUri != null) {
- val name = getDisplayPathFromUri(appContext, oldUri)
- val migrated = SyncedFolder(oldUri, name, oldTime, ANDROID_SYNCABLE_FILE_TYPES)
- folders.add(migrated)
- saveSyncedFoldersToPrefs(folders)
-
- prefs.edit {
- remove(KEY_SYNCED_FOLDER_URI)
- remove(KEY_LAST_FOLDER_SCAN_TIME)
- }
- }
- } else if (jsonString != null) {
- try {
- val jsonArray = JSONArray(jsonString)
- for (i in 0 until jsonArray.length()) {
- val obj = jsonArray.getJSONObject(i)
-
- val allowedFileTypes = mutableSetOf()
- if (obj.has("allowedFileTypes")) {
- val typesArray = obj.getJSONArray("allowedFileTypes")
- for (j in 0 until typesArray.length()) {
- try {
- allowedFileTypes.add(FileType.valueOf(typesArray.getString(j)))
- } catch (_: Exception) {}
- }
- } else {
- allowedFileTypes.addAll(ANDROID_SYNCABLE_FILE_TYPES)
- }
-
- folders.add(
- SyncedFolder(
- uriString = obj.getString("uri"),
- name = obj.getString("name"),
- lastScanTime = obj.optLong("lastScanTime", 0L),
- allowedFileTypes = allowedFileTypes.filterTo(mutableSetOf()) { it in ANDROID_SYNCABLE_FILE_TYPES }
- )
- )
- }
- } catch (e: Exception) {
- Timber.e(e, "Failed to parse synced folders JSON")
+ if (jsonString == null && prefs.contains(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI) && oldUri != null) {
+ saveSyncedFoldersToPrefs(folders)
+ prefs.edit {
+ remove(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI)
+ remove(SyncedFolderPrefs.KEY_LEGACY_LAST_FOLDER_SCAN_TIME)
}
}
return folders
}
private fun saveSyncedFoldersToPrefs(folders: List) {
- val jsonArray = JSONArray()
- folders.forEach { folder ->
- val obj = JSONObject()
- obj.put("uri", folder.uriString)
- obj.put("name", folder.name)
- obj.put("lastScanTime", folder.lastScanTime)
- val typesArray = JSONArray()
- folder.allowedFileTypes
- .filter { it in ANDROID_SYNCABLE_FILE_TYPES }
- .forEach { typesArray.put(it.name) }
- obj.put("allowedFileTypes", typesArray)
- jsonArray.put(obj)
+ prefs.edit {
+ putString(
+ SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON,
+ SyncedFolderPrefs.encodeSyncedFolders(folders)
+ )
}
- prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) }
}
fun addSyncedFolder(folderUri: Uri) {
@@ -2482,7 +2972,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
val name = getDisplayPathFromUri(appContext, folderUri.toString())
- val newFolder = SyncedFolder(folderUri.toString(), name, 0L, ANDROID_SYNCABLE_FILE_TYPES)
+ val newFolder = SyncedFolder(
+ uriString = folderUri.toString(),
+ name = name,
+ lastScanTime = 0L,
+ allowedFileTypes = ANDROID_SYNCABLE_FILE_TYPES,
+ localSyncEnabled = true
+ )
val newStats = currentFolders + newFolder
saveSyncedFoldersToPrefs(newStats)
@@ -2541,6 +3037,50 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ fun setFolderLocalSyncEnabled(
+ folder: SyncedFolder,
+ enabled: Boolean,
+ removeSyncDataFolder: Boolean = false
+ ) {
+ viewModelScope.launch {
+ val currentFolders = _internalState.value.syncedFolders.toMutableList()
+ val index = currentFolders.indexOfFirst { it.uriString == folder.uriString }
+ if (index == -1) return@launch
+
+ val updatedFolder = currentFolders[index].copy(localSyncEnabled = enabled)
+ currentFolders[index] = updatedFolder
+ saveSyncedFoldersToPrefs(currentFolders)
+ _internalState.update { it.copy(syncedFolders = currentFolders) }
+
+ if (enabled) {
+ showBanner(appContext.getString(R.string.banner_folder_local_sync_enabled))
+ triggerFolderSyncWorker(
+ metadataOnly = false,
+ showFeedback = true,
+ targetFolderUriString = updatedFolder.uriString
+ )
+ } else {
+ val workManager = WorkManager.getInstance(appContext)
+ workManager.cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
+ workManager.cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
+
+ if (removeSyncDataFolder) {
+ val removed = withContext(Dispatchers.IO) {
+ LocalSyncUtils.deleteSyncDataFolder(appContext, updatedFolder.uriString.toUri())
+ }
+ val message = if (removed) {
+ appContext.getString(R.string.banner_folder_local_sync_disabled_removed_data)
+ } else {
+ appContext.getString(R.string.banner_folder_sync_data_remove_failed)
+ }
+ showBanner(message, isError = !removed)
+ } else {
+ showBanner(appContext.getString(R.string.banner_folder_local_sync_disabled))
+ }
+ }
+ }
+ }
+
fun syncFolderMetadata(showFeedback: Boolean = false) {
triggerFolderSyncWorker(metadataOnly = true, showFeedback = showFeedback)
}
@@ -2554,11 +3094,21 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
showFeedback: Boolean,
targetFolderUriString: String? = null
) {
- val folders = _internalState.value.syncedFolders
- if (folders.isEmpty()) return
+ val allFolders = _internalState.value.syncedFolders
+ val folders = if (targetFolderUriString.isNullOrBlank()) {
+ allFolders.filter { it.localSyncEnabled }
+ } else {
+ allFolders.filter { it.uriString == targetFolderUriString && it.localSyncEnabled }
+ }
+ if (folders.isEmpty()) {
+ if (showFeedback) {
+ showBanner(appContext.getString(R.string.error_no_enabled_folder_sync), isError = true)
+ }
+ return
+ }
val targetFolderName = targetFolderUriString
- ?.let { target -> folders.firstOrNull { it.uriString == target }?.name ?: target }
+ ?.let { target -> allFolders.firstOrNull { it.uriString == target }?.name ?: target }
ReaderPerfLog.d(
"FolderSync request folders=${folders.size} target=${targetFolderName ?: "ALL"} " +
"metadataOnly=$metadataOnly feedback=$showFeedback"
@@ -2690,8 +3240,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
prefs.edit {
- remove(KEY_SYNCED_FOLDERS_JSON)
- remove(KEY_SYNCED_FOLDER_URI)
+ remove(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON)
+ remove(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI)
}
_internalState.update { it.copy(syncedFolders = emptyList()) }
}
@@ -2718,8 +3268,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
it.name
}
- val fileExtension = item.type.name.lowercase()
- val fileName = "${item.bookId}.$fileExtension"
+ val fileName = sharedCloudBookContentFileName(item.bookId, item.type)
+ ?: throw Exception("Unsupported cloud file type: ${item.type}")
val driveFileId = remoteFiles[fileName]?.id
if (driveFileId != null) {
@@ -2729,6 +3279,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
accessToken, driveFileId, destinationFile
)
) {
+ if (item.fileContentModifiedTimestamp > 0L) {
+ destinationFile.setLastModified(item.fileContentModifiedTimestamp)
+ }
addFileToRecent(
destinationFile.toUri(),
item.type,
@@ -2987,25 +3540,38 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun shouldDownloadRemoteBookContent(local: RecentFileItem, remote: RecentFileItem): Boolean {
+ val localFile = local.getUri()?.path?.let(::File)
+ val localContentTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: localFile?.takeIf { it.isFile }?.lastModified()
+ ?: 0L
return local.sourceFolderUri == null &&
!local.isDeleted &&
- local.type == FileType.EPUB &&
- remote.type == FileType.EPUB &&
- !remote.isDeleted &&
- remote.fileContentModifiedTimestamp > 0L &&
- remote.fileContentModifiedTimestamp > local.fileContentModifiedTimestamp
+ local.type == remote.type &&
+ sharedCloudBookContentFileName(local.bookId, local.type) != null &&
+ shouldDownloadRemoteCloudBookContent(
+ localFileAvailable = local.isAvailable && localFile?.isFile != false,
+ localContentModifiedTimestamp = localContentTimestamp,
+ remoteContentModifiedTimestamp = remote.fileContentModifiedTimestamp,
+ remoteDeleted = remote.isDeleted
+ )
}
private fun shouldUploadLocalBookContent(local: RecentFileItem, remote: RecentFileItem?): Boolean {
+ val localFile = local.getUri()?.path?.let(::File)
+ val localContentTimestamp = local.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: localFile?.takeIf { it.isFile }?.lastModified()
+ ?: 0L
return local.sourceFolderUri == null &&
- local.type == FileType.EPUB &&
- local.fileContentModifiedTimestamp > 0L &&
- local.fileContentModifiedTimestamp > (remote?.fileContentModifiedTimestamp ?: 0L)
+ sharedCloudBookContentFileName(local.bookId, local.type) != null &&
+ shouldUploadLocalCloudBookContent(
+ localFileAvailable = local.isAvailable && localFile?.isFile == true,
+ localContentModifiedTimestamp = localContentTimestamp,
+ remoteContentModifiedTimestamp = remote?.fileContentModifiedTimestamp
+ )
}
private suspend fun downloadCloudBookFile(accessToken: String, remote: RecentFileItem): Boolean {
- val fileExtension = remote.type.name.lowercase()
- val fileName = "${remote.bookId}.$fileExtension"
+ val fileName = sharedCloudBookContentFileName(remote.bookId, remote.type) ?: return false
val driveFileId = googleDriveRepository.getFiles(accessToken)
?.files
.orEmpty()
@@ -3034,6 +3600,17 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
return true
}
+ private fun scheduleCloudContentRetry(bookIds: Set) {
+ if (bookIds.isEmpty() || cloudContentRetryJob?.isActive == true) return
+ cloudContentRetryJob = viewModelScope.launch {
+ delay(CLOUD_CONTENT_RETRY_DELAY_MILLIS)
+ if (uiState.value.isSyncEnabled) {
+ logCloudSyncTrace { "android.full_sync.content_retry books=${bookIds.joinToString()}" }
+ syncWithCloud(showBanner = false).join()
+ }
+ }
+ }
+
fun setFolderSyncEnabled(enabled: Boolean) {
prefs.edit { putBoolean(KEY_FOLDER_SYNC_ENABLED, enabled) }
_internalState.update { it.copy(isFolderSyncEnabled = enabled) }
@@ -3048,12 +3625,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val currentUser = _internalState.value.currentUser
if (!hasPermissions || currentUser == null) {
+ logCloudSyncTrace {
+ "android.full_sync.skip reason=${if (!hasPermissions) "missing_drive_permissions" else "no_user"} " +
+ "showBanner=$showBanner"
+ }
if (showBanner) _internalState.update {
it.copy(errorMessage = appContext.getString(R.string.error_not_signed_in_sync))
}
return@launch
}
+ logCloudSyncTrace {
+ "android.full_sync.start user=${currentUser.uid} showBanner=$showBanner " +
+ "folderSync=${_internalState.value.isFolderSyncEnabled}"
+ }
if (showBanner) {
_internalState.update {
it.copy(bannerMessage = BannerMessage(appContext.getString(R.string.banner_cloud_sync_checking)))
@@ -3061,7 +3646,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
try {
- val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return@launch
+ val accessToken = googleDriveRepository.getAccessToken(appContext) ?: run {
+ logCloudSyncTrace { "android.full_sync.skip reason=no_access_token user=${currentUser.uid}" }
+ return@launch
+ }
val deviceId = getInstallationId()
val remoteBooksDeferred = async(Dispatchers.IO) {
@@ -3086,6 +3674,14 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val remoteBooks = remoteBooksDeferred.await()
.filterNot { it.isManualOnlyReaderFile() }
val remoteShelves = remoteShelvesDeferred.await()
+ val initialDriveFiles = withContext(Dispatchers.IO) {
+ googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name }
+ }
+ logCloudSyncTrace {
+ "android.full_sync.loaded user=${currentUser.uid} device=$deviceId " +
+ "localBooks=${localBooks.size} remoteBooks=${remoteBooks.size} " +
+ "remoteShelves=${remoteShelves.size} driveFiles=${initialDriveFiles.size}"
+ }
val syncableBookIds = (localBooks.map { it.bookId } + remoteBooks.map { it.bookId }).toSet()
val allKnownShelfNames =
(localShelfNames + remoteShelves.map { it.name }).toSet()
@@ -3103,17 +3699,23 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val localBooksMap = localBooks.associateBy { it.bookId }
val remoteBooksMap = remoteBooks.associateBy { it.bookId }
val allBookIds = (localBooksMap.keys + remoteBooksMap.keys).distinct()
+ val pendingContentDownloads = mutableSetOf()
allBookIds.forEach { bookId ->
val local = localBooksMap[bookId]
val remote = remoteBooksMap[bookId]
if (local?.sourceFolderUri != null) {
+ logCloudSyncTrace { "android.full_sync.book_skip reason=folder_book ${local.cloudSyncTraceSummary()}" }
Timber.d("Skipping cloud book metadata merge for local folder book: ${local.displayName}")
return@forEach
}
if (local != null && remote != null) {
+ logCloudSyncTrace {
+ "android.full_sync.compare book=$bookId ${local.cloudSyncTraceSummary()} " +
+ "${remote.cloudSyncTraceSummary()} "
+ }
Timber.tag("AnnotationSync").d(
"Checking $bookId. LocalTS: ${local.lastModifiedTimestamp}, RemoteTS: ${remote.lastModifiedTimestamp}, RemoteHasAnn: ${remote.hasAnnotations}"
)
@@ -3122,42 +3724,182 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
when {
local != null && remote == null -> {
if (local.isDeleted) {
+ logCloudSyncTrace { "android.full_sync.decision action=upload_deleted_metadata ${local.cloudSyncTraceSummary()}" }
uploadSingleBookMetadata(local)
} else {
+ logCloudSyncTrace { "android.full_sync.decision action=upload_new_book ${local.cloudSyncTraceSummary()}" }
uploadNewBookAndMetadata(local)
}
}
local == null && remote != null -> {
+ if (remote.isDeleted) {
+ logCloudSyncTrace { "android.full_sync.decision action=skip_deleted_remote_only ${remote.cloudSyncTraceSummary()}" }
+ return@forEach
+ }
+ logCloudSyncTrace { "android.full_sync.decision action=apply_remote_new ${remote.cloudSyncTraceSummary()}" }
recentFilesRepository.addRecentFile(remote.toRecentFileItem())
if (remote.hasAnnotations) {
- downloadAnnotationsForBook(accessToken, bookId)
+ val remoteAnnotationDriveTimestamp =
+ initialDriveFiles[cloudPdfAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L
+ val remoteAnnotationTimestamp = remote.effectiveAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp)
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.remote_only_download book=$bookId remoteTs=${remote.lastModifiedTimestamp} " +
+ "remoteAnnTs=$remoteAnnotationTimestamp remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " +
+ "remoteHasAnnotations=${remote.hasAnnotations}"
+ }
+ downloadAnnotationsForBook(accessToken, bookId, remoteAnnotationTimestamp)
}
}
local != null && remote != null -> {
val remoteItem = remote.toRecentFileItem()
- val shouldDownloadContent = shouldDownloadRemoteBookContent(local, remoteItem)
+ val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
+ val deletedInkFile = pdfAnnotationRepository.getDeletedAnnotationsFileForSync(bookId)
+ val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
+ val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
+ val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
+ val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
+ val localSidecars = AndroidPdfCloudSidecarState(
+ hasInk = inkFile.hasSyncableCloudAnnotationPayload(),
+ inkTimestamp = inkFile?.lastModified() ?: 0L,
+ hasDeletedInk = deletedInkFile.hasSyncableCloudAnnotationPayload(),
+ deletedInkTimestamp = deletedInkFile?.lastModified() ?: 0L,
+ hasRichText = richTextFile.hasSyncableCloudAnnotationPayload(),
+ richTextTimestamp = richTextFile.lastModified(),
+ hasLayout = layoutFile.exists(),
+ layoutTimestamp = layoutFile.lastModified(),
+ hasTextBoxes = textBoxFile.hasSyncableCloudAnnotationPayload(),
+ textBoxesTimestamp = textBoxFile.lastModified(),
+ hasHighlights = highlightFile.hasSyncableCloudAnnotationPayload(),
+ highlightsTimestamp = highlightFile.lastModified()
+ )
+ val fileLastModified = localSidecars.annotationPayloadTimestamp
+ val remoteAnnotationDriveTimestamp =
+ initialDriveFiles[cloudPdfAnnotationDriveFileName(bookId)]?.modifiedTimeMillis ?: 0L
+ val remoteAnnotationTimestamp = remote.effectiveAnnotationModifiedTimestamp(remoteAnnotationDriveTimestamp)
+ val localAnnotationsShouldUpload = shouldUploadLocalPdfCloudAnnotations(
+ localSidecars = localSidecars,
+ remoteHasAnnotations = remote.hasAnnotations,
+ remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp
+ )
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.inspect book=$bookId remoteHas=${remote.hasAnnotations} " +
+ "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
+ "remoteDriveAnnTs=$remoteAnnotationDriveTimestamp " +
+ "localTs=${local.lastModifiedTimestamp} " +
+ "remoteReadTs=${remote.effectiveReadingPositionModifiedTimestamp()} " +
+ "localReadTs=${local.effectiveReadingPositionModifiedTimestamp()} " +
+ "localPayload=${localSidecars.hasAnnotationPayload} " +
+ "localPayloadTs=${localSidecars.annotationPayloadTimestamp} " +
+ "layoutExists=${localSidecars.hasLayout} layoutTs=${localSidecars.layoutTimestamp} " +
+ "shouldUploadLocal=$localAnnotationsShouldUpload"
+ }
+ if (remote.isDeleted) {
+ val localWinsDeletedRemote = shouldUploadLocalCloudBookMetadataUpdate(
+ localModifiedTimestamp = local.lastModifiedTimestamp,
+ remoteModifiedTimestamp = remote.lastModifiedTimestamp
+ )
+ val remoteDeleteWins = shouldApplyRemoteCloudBookMetadataUpdate(
+ localModifiedTimestamp = local.lastModifiedTimestamp,
+ remoteModifiedTimestamp = remote.lastModifiedTimestamp
+ )
+ when {
+ localWinsDeletedRemote -> {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=resurrect_upload_local book=$bookId " +
+ "localTs=${local.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified"
+ }
+ if (shouldUploadLocalBookContent(local, null)) {
+ uploadNewBookAndMetadata(local)
+ } else {
+ uploadSingleBookMetadata(local)
+ }
+ }
+
+ remoteDeleteWins -> {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=apply_remote_delete book=$bookId " +
+ "localTs=${local.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified"
+ }
+ recentFilesRepository.deleteFilePermanently(listOf(bookId))
+ }
+
+ else -> {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=skip_equal_delete book=$bookId " +
+ "localTs=${local.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified"
+ }
+ }
+ }
+ return@forEach
+ }
+ val localWithRemoteEpubAnnotations = local.mergeRemoteEpubAnnotationMetadata(remote)
+ val effectiveLocal = if (localWithRemoteEpubAnnotations != local) {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=merge_remote_epub_annotations book=$bookId " +
+ "local=${local.cloudSyncTraceSummary()} ${remote.cloudSyncTraceSummary()} " +
+ "merged=${localWithRemoteEpubAnnotations.cloudSyncTraceSummary()}"
+ }
+ recentFilesRepository.addRecentFile(localWithRemoteEpubAnnotations)
+ localWithRemoteEpubAnnotations
+ } else {
+ local
+ }
+ val localReadingTimestamp = effectiveLocal.effectiveReadingPositionModifiedTimestamp()
+ val remoteReadingTimestamp = remote.effectiveReadingPositionModifiedTimestamp()
+ val localReadingPositionShouldUpload = localReadingTimestamp > remoteReadingTimestamp
+ val shouldDownloadContent = shouldDownloadRemoteBookContent(effectiveLocal, remoteItem)
val downloadedRemoteContent = if (shouldDownloadContent) {
- downloadCloudBookFile(accessToken, remoteItem.copy(displayName = remoteItem.displayName.ifBlank { local.displayName }))
+ logCloudSyncTrace {
+ "android.full_sync.content_download_start book=$bookId " +
+ "localContentTs=${effectiveLocal.fileContentModifiedTimestamp} remoteContentTs=${remote.fileContentModifiedTimestamp}"
+ }
+ downloadCloudBookFile(accessToken, remoteItem.copy(displayName = remoteItem.displayName.ifBlank { effectiveLocal.displayName }))
} else {
false
}
+ logCloudSyncTrace {
+ "android.full_sync.content_decision book=$bookId shouldDownload=$shouldDownloadContent " +
+ "downloaded=$downloadedRemoteContent localPayloadSidecarTs=$fileLastModified " +
+ "localLayoutTs=${localSidecars.layoutTimestamp.takeIf { localSidecars.hasLayout } ?: 0L}"
+ }
+ if (shouldDownloadContent && !downloadedRemoteContent) {
+ pendingContentDownloads += bookId
+ }
- if (local.lastModifiedTimestamp > remote.lastModifiedTimestamp) {
- if (shouldUploadLocalBookContent(local, remoteItem)) {
- uploadNewBookAndMetadata(local)
+ if (shouldUploadLocalCloudBookMetadataUpdate(
+ localModifiedTimestamp = effectiveLocal.lastModifiedTimestamp,
+ remoteModifiedTimestamp = remote.lastModifiedTimestamp
+ )
+ ) {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=upload_local book=$bookId " +
+ "localTs=${effectiveLocal.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} " +
+ "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp payloadSidecarTs=$fileLastModified " +
+ "uploadContent=${shouldUploadLocalBookContent(effectiveLocal, remoteItem)}"
+ }
+ if (shouldUploadLocalBookContent(effectiveLocal, remoteItem)) {
+ uploadNewBookAndMetadata(effectiveLocal)
} else {
- uploadSingleBookMetadata(local)
+ uploadSingleBookMetadata(effectiveLocal)
}
} else {
val isMetadataNewer =
- remote.lastModifiedTimestamp > local.lastModifiedTimestamp
+ shouldApplyRemoteCloudBookMetadataUpdate(
+ localModifiedTimestamp = effectiveLocal.lastModifiedTimestamp,
+ remoteModifiedTimestamp = remote.lastModifiedTimestamp
+ )
if (isMetadataNewer) {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=apply_remote_metadata book=$bookId " +
+ "localTs=${effectiveLocal.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified " +
+ "downloadedContent=$downloadedRemoteContent"
+ }
val remoteForLocalDb = if (shouldDownloadContent && !downloadedRemoteContent) {
remote.toRecentFileItem().copy(
- fileContentModifiedTimestamp = local.fileContentModifiedTimestamp
+ fileContentModifiedTimestamp = effectiveLocal.fileContentModifiedTimestamp
)
} else {
remote.toRecentFileItem()
@@ -3165,31 +3907,76 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
recentFilesRepository.addRecentFile(
remoteForLocalDb
)
+ if (localAnnotationsShouldUpload || localReadingPositionShouldUpload) {
+ recentFilesRepository.getFileByBookId(bookId)?.let { merged ->
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.upload_local_annotations book=$bookId reason=remote_metadata_newer " +
+ "remoteTs=${remote.lastModifiedTimestamp} localPayloadTs=$fileLastModified " +
+ "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp " +
+ "uploadReadingPosition=$localReadingPositionShouldUpload"
+ }
+ logCloudSyncTrace {
+ "android.full_sync.decision action=upload_local_supplement book=$bookId " +
+ "remoteMetadataTs=${remote.lastModifiedTimestamp} payloadSidecarTs=$fileLastModified " +
+ "uploadAnnotations=$localAnnotationsShouldUpload uploadReadingPosition=$localReadingPositionShouldUpload"
+ }
+ uploadSingleBookMetadata(merged)
+ }
+ }
+ } else {
+ logCloudSyncTrace {
+ "android.full_sync.decision action=metadata_noop book=$bookId " +
+ "localTs=${effectiveLocal.lastModifiedTimestamp} remoteTs=${remote.lastModifiedTimestamp} " +
+ "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp payloadSidecarTs=$fileLastModified"
+ }
+ if (localAnnotationsShouldUpload || localReadingPositionShouldUpload) {
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.upload_local_annotations book=$bookId reason=metadata_noop " +
+ "remoteTs=${remote.lastModifiedTimestamp} localPayloadTs=$fileLastModified " +
+ "localReadTs=$localReadingTimestamp remoteReadTs=$remoteReadingTimestamp"
+ }
+ logCloudSyncTrace {
+ "android.full_sync.decision action=upload_local_supplement book=$bookId " +
+ "metadataEqual=${effectiveLocal.lastModifiedTimestamp == remote.lastModifiedTimestamp} " +
+ "uploadAnnotations=$localAnnotationsShouldUpload uploadReadingPosition=$localReadingPositionShouldUpload " +
+ "payloadSidecarTs=$fileLastModified"
+ }
+ uploadSingleBookMetadata(effectiveLocal)
+ }
}
- val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
- val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
- val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
- val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
- val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
-
- val anyLocalFileExists =
- (inkFile?.exists() == true) || richTextFile.exists() || layoutFile.exists() || textBoxFile.exists() || highlightFile.exists()
- val localFileMissing = !anyLocalFileExists
-
- val fileLastModified = maxOf(
- inkFile?.lastModified() ?: 0L,
- richTextFile.lastModified(),
- layoutFile.lastModified(),
- textBoxFile.lastModified(),
- highlightFile.lastModified()
+ val shouldDownloadRemoteAnnotations = shouldDownloadRemotePdfCloudAnnotations(
+ localSidecars = localSidecars,
+ localAnnotationsShouldUpload = localAnnotationsShouldUpload,
+ remoteHasAnnotations = remote.hasAnnotations,
+ remoteAnnotationModifiedTimestamp = remoteAnnotationTimestamp
)
- val isFileStale =
- remote.hasAnnotations && (remote.lastModifiedTimestamp > fileLastModified)
- if (isMetadataNewer || localFileMissing && remote.hasAnnotations || isFileStale) {
+ if (shouldDownloadRemoteAnnotations) {
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.download_remote_annotations book=$bookId " +
+ "metadataNewer=$isMetadataNewer localPayloadMissing=${!localSidecars.hasAnnotationPayload} " +
+ "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
+ "localPayloadTs=$fileLastModified " +
+ "layoutTs=${localSidecars.layoutTimestamp.takeIf { localSidecars.hasLayout } ?: 0L}"
+ }
+ logCloudSyncTrace {
+ "android.full_sync.sidecar_download_start book=$bookId reason=" +
+ "metadataNewer=$isMetadataNewer localPayloadMissing=${!localSidecars.hasAnnotationPayload} " +
+ "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
+ "localPayloadSidecarTs=$fileLastModified " +
+ "localLayoutTs=${localSidecars.layoutTimestamp.takeIf { localSidecars.hasLayout } ?: 0L}"
+ }
Timber.tag("AnnotationSync").d("Triggering download for $bookId.")
- downloadAnnotationsForBook(accessToken, bookId)
+ downloadAnnotationsForBook(accessToken, bookId, remoteAnnotationTimestamp)
+ } else {
+ logCloudAnnotationSyncTrace {
+ "android.full_sync.skip_remote_annotations book=$bookId " +
+ "remoteHas=${remote.hasAnnotations} localShouldUpload=$localAnnotationsShouldUpload " +
+ "metadataNewer=$isMetadataNewer localPayload=${localSidecars.hasAnnotationPayload} " +
+ "remoteTs=${remote.lastModifiedTimestamp} remoteAnnTs=$remoteAnnotationTimestamp " +
+ "localPayloadTs=$fileLastModified"
+ }
}
}
}
@@ -3274,41 +4061,80 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
googleDriveRepository.getFiles(accessToken)?.files.orEmpty().associateBy { it.name }
}
- val downloadJobs = mutableListOf()
-
finalMergedBooks.forEach { book ->
if (book.sourceFolderUri != null) return@forEach
- val fileExtension = book.type.name.lowercase()
- val fileName = "${book.bookId}.$fileExtension"
+ val fileName = sharedCloudBookContentFileName(book.bookId, book.type) ?: return@forEach
if (book.isDeleted) {
remoteFiles[fileName]?.id?.let { fileId ->
Timber.d("Deleting from Drive: $fileName")
googleDriveRepository.deleteDriveFile(accessToken, fileId)
}
+ remoteFiles[cloudPdfAnnotationDriveFileName(book.bookId)]?.id?.let { fileId ->
+ Timber.d("Deleting annotation bundle from Drive: ${book.bookId}")
+ googleDriveRepository.deleteDriveFile(accessToken, fileId)
+ }
recentFilesRepository.deleteFilePermanently(listOf(book.bookId))
} else if (
book.sourceFolderUri == null &&
book.isAvailable &&
!remoteFiles.containsKey(fileName)
) {
- book.getUri()?.path?.let { path ->
- val file = File(path)
- if (file.exists()) {
- Timber.d("Uploading book: ${book.displayName}")
- googleDriveRepository.uploadFile(
- accessToken, book.bookId, file, book.type
- )
+ val remoteItem = remoteBooksMap[book.bookId]?.toRecentFileItem()
+ if (remoteItem == null || shouldUploadLocalBookContent(book, remoteItem)) {
+ book.getUri()?.path?.let { path ->
+ val file = File(path)
+ if (file.exists()) {
+ Timber.d("Uploading book: ${book.displayName}")
+ val uploadedFile = googleDriveRepository.uploadFile(
+ accessToken, book.bookId, file, book.type
+ )
+ if (uploadedFile != null) {
+ val contentTimestamp = book.fileContentModifiedTimestamp.takeIf { it > 0L }
+ ?: file.lastModified()
+ uploadSingleBookMetadata(
+ book.copy(
+ fileSize = file.length(),
+ fileContentModifiedTimestamp = contentTimestamp
+ )
+ )
+ }
+ }
+ }
+ } else {
+ pendingContentDownloads += book.bookId
+ logCloudSyncTrace {
+ "android.full_sync.content_wait_missing_remote book=${book.bookId} " +
+ "file=$fileName localContentTs=${book.fileContentModifiedTimestamp} " +
+ "remoteContentTs=${remoteItem.fileContentModifiedTimestamp}"
}
}
} else if (!book.isAvailable && remoteFiles.containsKey(fileName)) {
Timber.d("Sync: Triggering auto-download for ${book.displayName}")
- downloadJobs.add(downloadBook(book))
+ val remoteItem = remoteBooksMap[book.bookId]
+ ?.toRecentFileItem()
+ ?.copy(displayName = book.displayName)
+ ?: book
+ val downloaded = downloadCloudBookFile(accessToken, remoteItem)
+ if (!downloaded) {
+ pendingContentDownloads += book.bookId
+ }
+ } else if (!book.isAvailable) {
+ pendingContentDownloads += book.bookId
}
}
- downloadJobs.joinAll()
+ if (pendingContentDownloads.isNotEmpty()) {
+ logCloudSyncTrace {
+ "android.full_sync.content_pending books=${pendingContentDownloads.joinToString()}"
+ }
+ scheduleCloudContentRetry(pendingContentDownloads)
+ } else {
+ cloudContentRetryJob?.cancel()
+ cloudContentRetryJob = null
+ }
syncFonts(currentUser.uid)
+ logCloudSyncTrace { "android.full_sync.complete user=${currentUser.uid}" }
if (showBanner) {
_internalState.update {
it.copy(
@@ -3317,6 +4143,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
} catch (e: Exception) {
+ logCloudSyncError(e) { "android.full_sync.failed user=${currentUser.uid}" }
Timber.tag("AnnotationSync").e(e, "Error during cloud sync")
if (showBanner) {
_internalState.update {
@@ -3326,21 +4153,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
- private suspend fun downloadAnnotationsForBook(accessToken: String, bookId: String) {
+ private suspend fun downloadAnnotationsForBook(
+ accessToken: String,
+ bookId: String,
+ annotationModifiedTimestamp: Long
+ ) {
// We download to a temp location first to inspect the content
val tempDownloadFile = File(appContext.cacheDir, "temp_download_${bookId}.json")
+ logCloudSyncTrace {
+ "android.sidecar_download.start book=$bookId remoteAnnTs=$annotationModifiedTimestamp temp=${tempDownloadFile.name}"
+ }
+ logCloudAnnotationSyncTrace {
+ "android.download.start book=$bookId remoteAnnTs=$annotationModifiedTimestamp temp=${tempDownloadFile.name}"
+ }
Timber.tag("AnnotationSync").d("Attempting download of bundle for $bookId.")
val didDownload =
googleDriveRepository.downloadAnnotationFile(accessToken, bookId, tempDownloadFile)
if (didDownload && tempDownloadFile.exists()) {
+ logCloudAnnotationSyncTrace {
+ "android.download.success book=$bookId remoteAnnTs=$annotationModifiedTimestamp bytes=${tempDownloadFile.length()}"
+ }
+ logCloudSyncTrace {
+ "android.sidecar_download.success book=$bookId remoteAnnTs=$annotationModifiedTimestamp bytes=${tempDownloadFile.length()}"
+ }
Timber.tag("AnnotationSync")
.d("Download SUCCESS. Size: ${tempDownloadFile.length()}. Unpacking...")
try {
val jsonString = tempDownloadFile.readText()
+ val appliedAnnotationTimestamp =
+ annotationModifiedTimestamp.takeIf { it > 0L }
+ ?: tempDownloadFile.lastModified().takeIf { it > 0L }
+ ?: 0L
Timber.d(
"android.cloud.import.downloaded book=$bookId rawLen=${jsonString.length}"
)
@@ -3358,16 +4205,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} catch (_: Exception) {
false
}
+ logCloudAnnotationSyncTrace {
+ "android.download.inspect book=$bookId isBundle=$isBundle rawBytes=${jsonString.length} " +
+ "appliedAnnTs=$appliedAnnotationTimestamp rawPreview=${jsonString.take(80).replace('\n', ' ')}"
+ }
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId) ?: File(
appContext.filesDir, "annotations/annotation_$bookId.json"
)
+ val deletedInkFile = File(appContext.filesDir, "annotations/deleted_annotation_$bookId.json")
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
val layoutFile = pageLayoutRepository.getLayoutFile(bookId)
val textBoxFile = pdfTextBoxRepository.getFileForSync(bookId)
val highlightFile = pdfHighlightRepository.getFileForSync(bookId)
inkFile.parentFile?.mkdirs()
+ deletedInkFile.parentFile?.mkdirs()
richTextFile.parentFile?.mkdirs()
layoutFile.parentFile?.mkdirs()
textBoxFile.parentFile?.mkdirs()
@@ -3380,44 +4233,94 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d(
"android.cloud.import.bundle book=$bookId hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
)
+ logCloudAnnotationSyncTrace {
+ "android.download.bundle_keys book=$bookId keys=${bundle.keys().asSequence().toList()} " +
+ "hasInk=${bundle.has("ink")} hasText=${bundle.has("text")} " +
+ "hasLayout=${bundle.has("layout")} hasTextBoxes=${bundle.has("textBoxes")} " +
+ "hasHighlights=${bundle.has("highlights")} " +
+ "hasDeletedInk=${bundle.has(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS)}"
+ }
fun writeSafe(key: String, file: File) {
if (bundle.has(key)) {
file.parentFile?.mkdirs()
val content = bundle.get(key).toString()
file.writeText(content)
+ appliedAnnotationTimestamp.takeIf { it > 0L }?.let(file::setLastModified)
+ logCloudAnnotationSyncTrace {
+ "android.download.write key=$key book=$bookId bytes=${content.length} " +
+ "path=${file.absolutePath.cloudSyncPreview(140)} ts=${file.lastModified()}"
+ }
if (key == "text") {
Timber.d(
"android.cloud.import.writeRichText book=$bookId rawLen=${content.length} file=${file.absolutePath}"
)
}
} else {
+ if (key == "layout") {
+ logCloudAnnotationSyncTrace {
+ "android.download.preserve_missing key=layout book=$bookId " +
+ "path=${file.absolutePath.cloudSyncPreview(140)} exists=${file.exists()}"
+ }
+ Timber.d(
+ "android.cloud.import.preserveMissingLayout book=$bookId file=${file.absolutePath}"
+ )
+ return
+ }
if (key == "text" && file.exists()) {
Timber.d(
"android.cloud.import.deleteMissingRichText book=$bookId file=${file.absolutePath}"
)
}
- if (file.exists()) file.delete()
+ if (file.exists()) {
+ val deleted = file.delete()
+ logCloudAnnotationSyncTrace {
+ "android.download.delete_missing key=$key book=$bookId deleted=$deleted " +
+ "path=${file.absolutePath.cloudSyncPreview(140)}"
+ }
+ } else {
+ logCloudAnnotationSyncTrace {
+ "android.download.missing_key key=$key book=$bookId path=${file.absolutePath.cloudSyncPreview(140)}"
+ }
+ }
}
}
writeSafe("ink", inkFile)
+ writeSafe(SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS, deletedInkFile)
writeSafe("text", richTextFile)
writeSafe("layout", layoutFile)
writeSafe("textBoxes", textBoxFile)
writeSafe("highlights", highlightFile)
+ logCloudSyncTrace {
+ "android.sidecar_download.applied_bundle book=$bookId remoteAnnTs=$annotationModifiedTimestamp " +
+ "keys=${bundle.keys().asSequence().toList()}"
+ }
Timber.tag("AnnotationSync").d("Unpacked unified bundle.")
} else {
Timber.tag("AnnotationSync").d("Detected legacy format (Ink only).")
inkFile.writeText(jsonString)
+ appliedAnnotationTimestamp.takeIf { it > 0L }?.let(inkFile::setLastModified)
+ logCloudAnnotationSyncTrace {
+ "android.download.write_legacy_ink book=$bookId bytes=${jsonString.length} " +
+ "path=${inkFile.absolutePath.cloudSyncPreview(140)} ts=${inkFile.lastModified()}"
+ }
+ logCloudSyncTrace { "android.sidecar_download.applied_legacy book=$bookId remoteAnnTs=$annotationModifiedTimestamp" }
}
} catch (e: Exception) {
+ logCloudAnnotationSyncError(e) { "android.download.apply_failed book=$bookId remoteAnnTs=$annotationModifiedTimestamp" }
+ logCloudSyncError(e) { "android.sidecar_download.apply_failed book=$bookId remoteAnnTs=$annotationModifiedTimestamp" }
Timber.e(e, "Error unpacking synced annotation data")
} finally {
tempDownloadFile.delete()
}
} else {
+ logCloudAnnotationSyncTrace {
+ "android.download.missing book=$bookId remoteAnnTs=$annotationModifiedTimestamp didDownload=$didDownload " +
+ "tempExists=${tempDownloadFile.exists()} tempBytes=${tempDownloadFile.length()}"
+ }
+ logCloudSyncTrace { "android.sidecar_download.missing book=$bookId remoteAnnTs=$annotationModifiedTimestamp" }
Timber.tag("AnnotationSync")
.d("FAILURE: No bundle found on Drive for $bookId (or download failed)")
}
@@ -3611,7 +4514,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
}
}
- } else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) {
+ } else if (uri.scheme != "opds-pse" && type in COMIC_ARCHIVE_FILE_TYPES) {
if (coverPath == null) {
var cacheFile: File? = null
try {
@@ -3641,7 +4544,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
archiveDoc.close()
} catch (e: Exception) {
- Timber.e(e, "Error generating CBZ cover")
+ Timber.e(e, "Error generating comic archive cover")
} finally {
try {
if (cacheFile?.exists() == true) {
@@ -3975,6 +4878,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val (internalUri, bookId, type) = importResult
if (isExternalIntent) {
externalOpenedBookId = bookId
+ if (prefs.getString(KEY_EXTERNAL_FILE_BEHAVIOR, "ASK") == "DELETE") {
+ markPendingExternalFileRemoval(bookId, internalUri.toString())
+ }
}
val displayName = getFileNameFromUri(externalUri, appContext) ?: "Unknown File"
openBook(
@@ -4027,9 +4933,28 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val currentBookUri = _internalState.value.selectedPdfUri ?: _internalState.value.selectedEpubUri
if (currentBookUri != null) {
recentFilesRepository.getFileByUri(currentBookUri.toString())?.let { item ->
+ if (annotationJsonEquivalentForNoop(item.highlightsJson, highlightsJson)) {
+ logCloudSyncTrace {
+ "android.reader.highlights_save_noop book=${item.bookId} highlights=${highlightsJson.cloudSyncAnnotationSummary()}"
+ }
+ return@launch
+ }
+ logCloudSyncTrace {
+ "android.reader.highlights_save book=${item.bookId} highlights=${highlightsJson.cloudSyncAnnotationSummary()}"
+ }
recentFilesRepository.updateHighlights(item.bookId, highlightsJson)
}
} else if (bookId.isNotBlank()) {
+ val existing = recentFilesRepository.getFileByBookId(bookId)
+ if (annotationJsonEquivalentForNoop(existing?.highlightsJson, highlightsJson)) {
+ logCloudSyncTrace {
+ "android.reader.highlights_save_noop book=$bookId highlights=${highlightsJson.cloudSyncAnnotationSummary()}"
+ }
+ return@launch
+ }
+ logCloudSyncTrace {
+ "android.reader.highlights_save book=$bookId highlights=${highlightsJson.cloudSyncAnnotationSummary()}"
+ }
recentFilesRepository.updateHighlights(bookId, highlightsJson)
}
}
@@ -4401,6 +5326,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
} else {
null
}
+ logCloudSyncTrace {
+ "android.reader.open_epub_position book=$bookId " +
+ "overrideLocator=$initialLocatorOverride overrideCfi=${initialCfiOverride.cloudSyncPreview()} " +
+ (recentItem?.cloudSyncTraceSummary("recent") ?: "recent=null") +
+ " chosenLocator=$locator chosenCfi=${(initialCfiOverride ?: recentItem?.lastPositionCfi).cloudSyncPreview()}"
+ }
_internalState.update {
it.copy(
@@ -4730,13 +5661,24 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) {
Timber.d("Saving EPUB position locally: URI=$uri, Locator=$locator")
viewModelScope.launch {
- recentFilesRepository.getFileByUri(uri.toString())?.let { _ ->
+ recentFilesRepository.getFileByUri(uri.toString())?.let { existing ->
+ logCloudSyncTrace {
+ "android.reader.position_save_start book=${existing.bookId} beforeTs=${existing.lastModifiedTimestamp} " +
+ "locator={chapter=${locator.chapterIndex} block=${locator.blockIndex} char=${locator.charOffset}} " +
+ "progress=$progress cfi=${cfiForWebView.cloudSyncPreview()}"
+ }
recentFilesRepository.updateEpubReadingPosition(
uriString = uri.toString(),
locator = locator,
cfiForWebView = cfiForWebView,
progress = progress
)
+ val updated = recentFilesRepository.getFileByBookId(existing.bookId)
+ logCloudSyncTrace {
+ "android.reader.position_save_done beforeTs=${existing.lastModifiedTimestamp} " +
+ (updated?.cloudSyncTraceSummary("after") ?: "after=null")
+ }
+ queueCloudMetadataUpload(existing.bookId, reason = "epub_position")
}
}
}
@@ -4778,10 +5720,20 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
Timber.tag("PdfPositionDebug").i("ViewModel: Save request triggered | Page: $page | Total: $totalPages | Progress: $progress | URI: ${currentPdfUri.lastPathSegment}")
viewModelScope.launch {
- recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { _ ->
+ recentFilesRepository.getFileByUri(currentPdfUri.toString())?.let { existing ->
+ logCloudSyncTrace {
+ "android.reader.pdf_position_save_start book=${existing.bookId} beforeTs=${existing.lastModifiedTimestamp} " +
+ "beforeReadTs=${existing.effectiveReadingPositionModifiedTimestamp()} page=$page progress=$progress"
+ }
recentFilesRepository.updatePdfReadingPosition(
uriString = currentPdfUri.toString(), page = page, progress = progress
)
+ val updated = recentFilesRepository.getFileByBookId(existing.bookId)
+ logCloudSyncTrace {
+ "android.reader.pdf_position_save_done beforeTs=${existing.lastModifiedTimestamp} " +
+ (updated?.cloudSyncTraceSummary("after") ?: "after=null")
+ }
+ queueCloudMetadataUpload(existing.bookId, reason = "pdf_position")
} ?: run {
Timber.tag("PdfPositionDebug").e("ViewModel: Save aborted. Could not resolve file item from URI in DB.")
}
@@ -4831,7 +5783,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
fun refreshLibrary() {
val syncEnabled = _internalState.value.isSyncEnabled
- val hasFolder = _internalState.value.syncedFolders.isNotEmpty()
+ val hasFolder = _internalState.value.syncedFolders.any { it.localSyncEnabled }
if (!syncEnabled && !hasFolder) {
Timber.d("Refresh skipped: No sync methods active.")
@@ -4924,19 +5876,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
private fun uploadNewBookAndMetadata(book: RecentFileItem) {
- if (!uiState.value.isSyncEnabled) return
+ if (!uiState.value.isSyncEnabled) {
+ logCloudSyncTrace { "android.upload_content.skip reason=sync_disabled ${book.cloudSyncTraceSummary()}" }
+ return
+ }
if (book.uriString?.startsWith("opds-pse") == true) {
+ logCloudSyncTrace { "android.upload_content.skip reason=opds_stream ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping book content sync for OPDS stream book: ${book.displayName}")
return
}
if (book.sourceFolderUri != null) {
+ logCloudSyncTrace { "android.upload_content.skip reason=folder_book ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping book content sync for local folder book: ${book.displayName}")
return
}
if (book.isManualOnlyReaderFile()) {
+ logCloudSyncTrace { "android.upload_content.skip reason=manual_only ${book.cloudSyncTraceSummary()}" }
Timber.d("Skipping book content sync for manual-only reader file: ${book.displayName}")
return
}
@@ -4944,16 +5902,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
viewModelScope.launch {
_internalState.update { it.copy(uploadingBookIds = it.uploadingBookIds + book.bookId) }
try {
- val accessToken = googleDriveRepository.getAccessToken(appContext) ?: return@launch
+ logCloudSyncTrace { "android.upload_content.start ${book.cloudSyncTraceSummary()}" }
+ val accessToken = googleDriveRepository.getAccessToken(appContext) ?: run {
+ logCloudSyncTrace { "android.upload_content.skip reason=no_access_token ${book.cloudSyncTraceSummary()}" }
+ return@launch
+ }
book.getUri()?.path?.let { path ->
val file = File(path)
if (file.exists()) {
+ logCloudSyncTrace { "android.upload_content.file book=${book.bookId} path=${path.cloudSyncPreview()} bytes=${file.length()}" }
Timber.d("Uploading newly added book content: ${book.displayName}")
val uploadedFile = googleDriveRepository.uploadFile(
accessToken, book.bookId, file, book.type
)
if (uploadedFile != null) {
+ logCloudSyncTrace { "android.upload_content.success book=${book.bookId} driveId=${uploadedFile.id}" }
Timber.d("Upload successful, now syncing metadata for ${book.bookId}")
val latestBookState = recentFilesRepository.getFileByBookId(book.bookId)
if (latestBookState != null) {
@@ -4962,13 +5926,16 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
uploadSingleBookMetadata(book)
}
} else {
+ logCloudSyncTrace { "android.upload_content.failed_null book=${book.bookId}" }
Timber.e("Google Drive upload returned null for ${book.bookId}")
}
} else {
+ logCloudSyncTrace { "android.upload_content.skip reason=file_missing book=${book.bookId} path=${path.cloudSyncPreview()}" }
Timber.w("File for new book upload does not exist at path: $path")
}
}
} catch (e: Exception) {
+ logCloudSyncError(e) { "android.upload_content.failed ${book.cloudSyncTraceSummary()}" }
Timber.e(e, "Failed to upload new book content for bookId: ${book.bookId}")
} finally {
_internalState.update {
@@ -5029,8 +5996,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val newBehavior = if (keep) "KEEP" else "DELETE"
setExternalFileBehavior(newBehavior)
}
- if (!keep) {
- deleteBookPermanently(bookId)
+ if (keep) {
+ clearPendingExternalFileRemovals(setOf(bookId))
+ } else {
+ deletePendingExternalFileRemoval(bookId, null)
}
_internalState.update { it.copy(showExternalFileSavePromptFor = null) }
}
@@ -5399,10 +6368,15 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
deviceId
)
- val fileExtension = item.type.name.lowercase()
- val fileName = "${item.bookId}.$fileExtension"
- remoteFiles[fileName]?.id?.let { fileId ->
- Timber.d("Deleting from Drive: $fileName")
+ sharedCloudBookContentFileName(item.bookId, item.type)
+ ?.let { fileName ->
+ remoteFiles[fileName]?.id?.let { fileId ->
+ Timber.d("Deleting from Drive: $fileName")
+ googleDriveRepository.deleteDriveFile(accessToken, fileId)
+ }
+ }
+ remoteFiles[cloudPdfAnnotationDriveFileName(item.bookId)]?.id?.let { fileId ->
+ Timber.d("Deleting annotation bundle from Drive: ${item.bookId}")
googleDriveRepository.deleteDriveFile(accessToken, fileId)
}
@@ -6097,7 +7071,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_APP_OPEN_COUNT = "app_open_count"
internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri"
internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
- private const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
private const val MAX_FOLDER_LIMIT = 10
internal const val KEY_PINNED_HOME = "pinned_home_books"
internal const val KEY_PINNED_LIBRARY = "pinned_library_books"
@@ -6108,6 +7081,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private const val KEY_LAST_OPEN_BOOK_ID = "last_open_book_id"
private const val KEY_LAST_OPEN_FILE_TYPE = "last_open_file_type"
private const val KEY_EXTERNAL_FILE_BEHAVIOR = "external_file_behavior"
+ private const val KEY_PENDING_EXTERNAL_FILE_REMOVALS = "pending_external_file_removals"
private const val KEY_USE_STRICT_FILE_FILTER = "use_strict_file_filter"
private const val KEY_USE_PDF_FILE_NAME_AS_DISPLAY_NAME = "use_pdf_file_name_as_display_name"
private const val KEY_SCREEN_CAPTURE_PROTECTION = "screen_capture_protection_enabled"
@@ -6132,3 +7106,61 @@ private fun RecentFileItem.isManualOnlyReaderFile(): Boolean {
private fun BookMetadata.isManualOnlyReaderFile(): Boolean {
return isManualOnlyReaderFileName(displayName)
}
+
+private fun RecentFileItem.withFreshLocalReadingPositionForCloudUpload(
+ latestLocal: RecentFileItem?
+): RecentFileItem {
+ if (latestLocal == null || latestLocal.bookId != bookId) return this
+ val latestReadingTimestamp = latestLocal.effectiveReadingPositionModifiedTimestamp()
+ val currentReadingTimestamp = effectiveReadingPositionModifiedTimestamp()
+ val shouldRefresh = latestLocal.lastModifiedTimestamp > lastModifiedTimestamp ||
+ latestReadingTimestamp > currentReadingTimestamp
+ if (!shouldRefresh) return this
+
+ return latestLocal.copy(
+ fileSize = fileSize.takeIf { it > 0L } ?: latestLocal.fileSize,
+ fileContentModifiedTimestamp = maxOf(fileContentModifiedTimestamp, latestLocal.fileContentModifiedTimestamp),
+ isAvailable = isAvailable || latestLocal.isAvailable,
+ uriString = latestLocal.uriString ?: uriString,
+ bookmarksJson = latestLocal.bookmarksJson ?: bookmarksJson,
+ highlightsJson = latestLocal.highlightsJson ?: highlightsJson
+ )
+}
+
+private fun RecentFileItem.withCloudReadingPosition(remote: BookMetadata): RecentFileItem {
+ return copy(
+ lastChapterIndex = remote.lastChapterIndex,
+ lastPage = remote.lastPage,
+ lastPositionCfi = remote.lastPositionCfi,
+ locatorBlockIndex = remote.locatorBlockIndex,
+ locatorCharOffset = remote.locatorCharOffset,
+ progressPercentage = remote.progressPercentage,
+ readingPositionModifiedTimestamp = remote.effectiveReadingPositionModifiedTimestamp()
+ )
+}
+
+private fun RecentFileItem.withLocalReadingPosition(local: RecentFileItem): RecentFileItem {
+ return copy(
+ lastChapterIndex = local.lastChapterIndex,
+ lastPage = local.lastPage,
+ lastPositionCfi = local.lastPositionCfi,
+ locatorBlockIndex = local.locatorBlockIndex,
+ locatorCharOffset = local.locatorCharOffset,
+ progressPercentage = local.progressPercentage,
+ readingPositionModifiedTimestamp = local.effectiveReadingPositionModifiedTimestamp()
+ )
+}
+
+private fun RecentFileItem.withLocalStorageForCloudMetadata(local: RecentFileItem): RecentFileItem {
+ return copy(
+ uriString = local.uriString ?: uriString,
+ isAvailable = local.isAvailable || isAvailable,
+ coverImagePath = local.coverImagePath ?: coverImagePath,
+ sourceFolderUri = local.sourceFolderUri ?: sourceFolderUri,
+ fileSize = local.fileSize.takeIf { it > 0L } ?: fileSize,
+ fileContentModifiedTimestamp = maxOf(local.fileContentModifiedTimestamp, fileContentModifiedTimestamp),
+ folderTextMetadataParsed = local.folderTextMetadataParsed || folderTextMetadataParsed,
+ folderCoverMetadataParsed = local.folderCoverMetadataParsed || folderCoverMetadataParsed,
+ tags = local.tags
+ )
+}
diff --git a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
index c3dc3ab..de2d411 100644
--- a/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
+++ b/app/src/main/java/com/aryan/reader/MetadataExtractionWorker.kt
@@ -50,11 +50,20 @@ class MetadataExtractionWorker(
val sourceFolderUri = inputData.getString(KEY_SOURCE_FOLDER_URI)
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
- val hasLegacy = prefs.contains("synced_folder_uri")
- val hasNew = prefs.contains("synced_folders_list_json")
+ val linkedFolders = SyncedFolderPrefs.decodeSyncedFolders(
+ jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null)
+ )
+ val enabledFolderUris = linkedFolders
+ .filter { it.localSyncEnabled }
+ .mapTo(mutableSetOf()) { it.uriString }
- if (!hasLegacy && !hasNew) {
- ReaderPerfLog.d("MetadataWorker skipped: no linked folders")
+ if (enabledFolderUris.isEmpty()) {
+ ReaderPerfLog.d("MetadataWorker skipped: no linked folders with sync enabled")
+ return@withContext Result.success()
+ }
+ if (!sourceFolderUri.isNullOrBlank() && sourceFolderUri !in enabledFolderUris) {
+ ReaderPerfLog.d("MetadataWorker skipped: folder sync disabled folder=$sourceFolderUri")
return@withContext Result.success()
}
@@ -62,7 +71,9 @@ class MetadataExtractionWorker(
val filesToProcess = recentFilesRepository.getFolderBooksNeedingTextMetadata(
sourceFolderUri = sourceFolderUri,
limit = METADATA_WORKER_BOOK_BATCH_SIZE
- )
+ ).filter { item ->
+ item.sourceFolderUri != null && item.sourceFolderUri in enabledFolderUris
+ }
if (filesToProcess.isEmpty()) {
ReaderPerfLog.d("MetadataWorker skipped: no metadata pending folder=${sourceFolderUri ?: "ALL"}")
diff --git a/app/src/main/java/com/aryan/reader/ReaderBrightness.kt b/app/src/main/java/com/aryan/reader/ReaderBrightness.kt
index 9e9e1d9..0aca394 100644
--- a/app/src/main/java/com/aryan/reader/ReaderBrightness.kt
+++ b/app/src/main/java/com/aryan/reader/ReaderBrightness.kt
@@ -10,11 +10,16 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Remove
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
-import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -28,20 +33,37 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.core.content.edit
+import com.aryan.reader.shared.ui.ReaderMinimalSlider
import kotlin.math.roundToInt
private const val READER_PREFS_NAME = "reader_prefs"
private const val PREF_READER_BRIGHTNESS_USE_SYSTEM = "reader_brightness_use_system"
private const val PREF_READER_BRIGHTNESS_VALUE = "reader_brightness_value"
private const val DEFAULT_CUSTOM_BRIGHTNESS = 0.75f
-private const val MIN_CUSTOM_BRIGHTNESS = 0.05f
+private const val MIN_CUSTOM_BRIGHTNESS_PERCENT = 1
+private const val MAX_CUSTOM_BRIGHTNESS_PERCENT = 100
+private const val CUSTOM_BRIGHTNESS_STEP_PERCENT = 1
+private const val MIN_CUSTOM_BRIGHTNESS = 0.01f
data class ReaderBrightnessSettings(
val useSystemBrightness: Boolean = true,
val customBrightness: Float = DEFAULT_CUSTOM_BRIGHTNESS
) {
val safeCustomBrightness: Float
- get() = customBrightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
+ get() = normalizeReaderBrightness(customBrightness)
+}
+
+internal fun normalizeReaderBrightness(brightness: Float): Float {
+ val percent = (brightness * 100f).roundToInt()
+ .coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
+ return percent / 100f
+}
+
+internal fun stepReaderBrightness(brightness: Float, percentDelta: Int): Float {
+ val currentPercent = (normalizeReaderBrightness(brightness) * 100f).roundToInt()
+ val nextPercent = (currentPercent + percentDelta)
+ .coerceIn(MIN_CUSTOM_BRIGHTNESS_PERCENT, MAX_CUSTOM_BRIGHTNESS_PERCENT)
+ return nextPercent / 100f
}
fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
@@ -49,7 +71,7 @@ fun loadReaderBrightnessSettings(context: Context): ReaderBrightnessSettings {
return ReaderBrightnessSettings(
useSystemBrightness = prefs.getBoolean(PREF_READER_BRIGHTNESS_USE_SYSTEM, true),
customBrightness = prefs.getFloat(PREF_READER_BRIGHTNESS_VALUE, DEFAULT_CUSTOM_BRIGHTNESS)
- .coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
+ .let(::normalizeReaderBrightness)
)
}
@@ -162,17 +184,9 @@ fun ReaderBrightnessSheet(
color = MaterialTheme.colorScheme.primary
)
}
- Slider(
- value = settings.safeCustomBrightness,
- onValueChange = { brightness ->
- onSettingsChange(
- settings.copy(
- useSystemBrightness = false,
- customBrightness = brightness.coerceIn(MIN_CUSTOM_BRIGHTNESS, 1f)
- )
- )
- },
- valueRange = MIN_CUSTOM_BRIGHTNESS..1f
+ ReaderBrightnessControl(
+ settings = settings,
+ onSettingsChange = onSettingsChange
)
Text(
text = stringResource(R.string.reader_brightness_custom_desc),
@@ -186,6 +200,67 @@ fun ReaderBrightnessSheet(
}
}
+@Composable
+private fun ReaderBrightnessControl(
+ settings: ReaderBrightnessSettings,
+ onSettingsChange: (ReaderBrightnessSettings) -> Unit
+) {
+ val brightness = settings.safeCustomBrightness
+ val canDecrease = brightness > MIN_CUSTOM_BRIGHTNESS
+ val canIncrease = brightness < 1f
+
+ fun updateBrightness(value: Float) {
+ onSettingsChange(
+ settings.copy(
+ useSystemBrightness = false,
+ customBrightness = normalizeReaderBrightness(value)
+ )
+ )
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ IconButton(
+ onClick = {
+ updateBrightness(stepReaderBrightness(brightness, -CUSTOM_BRIGHTNESS_STEP_PERCENT))
+ },
+ enabled = canDecrease,
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Remove,
+ contentDescription = stringResource(R.string.content_desc_decrease),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ ReaderMinimalSlider(
+ value = brightness,
+ onValueChange = ::updateBrightness,
+ valueRange = MIN_CUSTOM_BRIGHTNESS..1f,
+ activeColor = MaterialTheme.colorScheme.primary,
+ inactiveColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f),
+ thumbColor = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.weight(1f)
+ )
+ IconButton(
+ onClick = {
+ updateBrightness(stepReaderBrightness(brightness, CUSTOM_BRIGHTNESS_STEP_PERCENT))
+ },
+ enabled = canIncrease,
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(R.string.content_desc_increase),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+}
+
private fun Window.setReaderBrightness(brightness: Float) {
attributes = attributes.apply {
screenBrightness = brightness
diff --git a/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt b/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt
index 9fb9623..df76113 100644
--- a/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt
+++ b/app/src/main/java/com/aryan/reader/ReaderSliderChromeState.kt
@@ -56,6 +56,21 @@ internal fun shouldRenderReaderSlider(
isSearchActive: Boolean
): Boolean = isToggledOn && isBottomChromeVisible && !isSearchActive
+internal fun readerSliderStepPage(
+ currentPage: Int,
+ delta: Int,
+ minPage: Int,
+ maxPage: Int
+): Int {
+ val lowerBound = min(minPage, maxPage)
+ val upperBound = max(minPage, maxPage)
+ val nextPage = currentPage.toLong() + delta.toLong()
+
+ return nextPage
+ .coerceIn(lowerBound.toLong(), upperBound.toLong())
+ .toInt()
+}
+
internal fun readerSliderTogglePreferenceKey(bookId: String): String =
READER_SLIDER_TOGGLE_PREFIX + bookId
diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt
index 71e3996..98a75d3 100644
--- a/app/src/main/java/com/aryan/reader/SharedComposables.kt
+++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt
@@ -142,9 +142,12 @@ import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.net.toUri
import androidx.core.text.HtmlCompat
+import com.aryan.reader.shared.SharedLegalLinks
+import com.aryan.reader.shared.SharedLegalProfile
import com.aryan.reader.data.BookMetadataEdit
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.shared.SharedText
+import com.aryan.reader.shared.sharedLegalLinksForProfile
import com.aryan.reader.shared.ui.SharedMarkdownText
import timber.log.Timber
import java.text.SimpleDateFormat
@@ -154,9 +157,25 @@ import kotlin.math.log10
import kotlin.math.pow
import kotlin.math.roundToInt
-internal const val PRIVACY_POLICY_URL = "https://aryan-raj3112.github.io/reader-policy/privacy-policy.html"
-internal const val TERMS_URL = "https://aryan-raj3112.github.io/reader-policy/terms-and-conditions.html"
-internal const val LICENSES_URL = "https://aryan-raj3112.github.io/reader-policy/licenses.html"
+internal fun legalLinksForAndroidFlavor(flavor: String = BuildConfig.FLAVOR): SharedLegalLinks {
+ val profile = if (flavor == "oss") SharedLegalProfile.OSS else SharedLegalProfile.STANDARD
+ return sharedLegalLinksForProfile(profile)
+}
+
+internal val PRIVACY_POLICY_URL: String get() = legalLinksForAndroidFlavor().privacyPolicyUrl
+internal val TERMS_URL: String get() = legalLinksForAndroidFlavor().termsUrl
+internal val LICENSES_URL: String get() = legalLinksForAndroidFlavor().licensesUrl
+
+fun supportedFontMimeTypes(): Array = arrayOf(
+ "font/ttf",
+ "font/otf",
+ "font/woff2",
+ "application/x-font-ttf",
+ "application/x-font-otf",
+ "application/font-woff2",
+ "application/vnd.ms-opentype",
+ "application/x-font-opentype"
+)
class CustomTabUriHandler(private val context: Context) : UriHandler {
override fun openUri(uri: String) {
@@ -1237,53 +1256,55 @@ fun AboutDialog(onDismiss: () -> Unit) {
subtitle = stringResource(R.string.about_github_desc),
onClick = { uriHandler.openUri("https://github.com/Aryan-Raj3112/episteme") }
)
- } else {
- AboutInfoRow(
- icon = {
- Icon(
- imageVector = Icons.Outlined.Policy,
- contentDescription = null,
- modifier = Modifier.size(22.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- },
- text = stringResource(R.string.legal_privacy_policy),
- subtitle = stringResource(R.string.about_privacy_desc),
- onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
- )
Spacer(modifier = Modifier.height(10.dp))
-
- AboutInfoRow(
- icon = {
- Icon(
- imageVector = Icons.Outlined.Gavel,
- contentDescription = null,
- modifier = Modifier.size(22.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- },
- text = stringResource(R.string.legal_terms_of_service),
- subtitle = stringResource(R.string.about_terms_desc),
- onClick = { uriHandler.openUri(TERMS_URL) }
- )
-
- Spacer(modifier = Modifier.height(10.dp))
-
- AboutInfoRow(
- icon = {
- Icon(
- imageVector = Icons.Outlined.FileOpen,
- contentDescription = null,
- modifier = Modifier.size(22.dp),
- tint = MaterialTheme.colorScheme.primary
- )
- },
- text = stringResource(R.string.legal_licenses),
- subtitle = stringResource(R.string.about_licenses_desc),
- onClick = { uriHandler.openUri(LICENSES_URL) }
- )
}
+
+ AboutInfoRow(
+ icon = {
+ Icon(
+ imageVector = Icons.Outlined.Policy,
+ contentDescription = null,
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ text = stringResource(R.string.legal_privacy_policy),
+ subtitle = stringResource(R.string.about_privacy_desc),
+ onClick = { uriHandler.openUri(PRIVACY_POLICY_URL) }
+ )
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ AboutInfoRow(
+ icon = {
+ Icon(
+ imageVector = Icons.Outlined.Gavel,
+ contentDescription = null,
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ text = stringResource(R.string.legal_terms_of_service),
+ subtitle = stringResource(R.string.about_terms_desc),
+ onClick = { uriHandler.openUri(TERMS_URL) }
+ )
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ AboutInfoRow(
+ icon = {
+ Icon(
+ imageVector = Icons.Outlined.FileOpen,
+ contentDescription = null,
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ },
+ text = stringResource(R.string.legal_licenses),
+ subtitle = stringResource(R.string.about_licenses_desc),
+ onClick = { uriHandler.openUri(LICENSES_URL) }
+ )
}
},
confirmButton = {
diff --git a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
index fa6c19e..001cd8b 100644
--- a/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
+++ b/app/src/main/java/com/aryan/reader/SharedModelMappers.kt
@@ -10,11 +10,13 @@ import com.aryan.reader.shared.BookShelfRef as SharedBookShelfRef
import com.aryan.reader.shared.EpubAnnotationSerializer
import com.aryan.reader.shared.FileType as SharedFileType
import com.aryan.reader.shared.LibraryFilters as SharedLibraryFilters
+import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
import com.aryan.reader.shared.SharedReaderScreenState
import com.aryan.reader.shared.Shelf as SharedShelf
import com.aryan.reader.shared.ShelfRecord
import com.aryan.reader.shared.SyncedFolder as SharedSyncedFolder
import com.aryan.reader.shared.Tag as SharedTag
+import com.aryan.reader.shared.toStablePositionCfi
fun FileType.toSharedFileType(): SharedFileType = this
@@ -29,11 +31,21 @@ fun SyncedFolder.toSharedSyncedFolder(): SharedSyncedFolder = this
fun SharedSyncedFolder.toAndroidSyncedFolder(): SyncedFolder = this
fun RecentFileItem.toSharedBookItem(): SharedBookItem {
+ return toSharedBookItem(
+ displayName = customName ?: displayName,
+ includeReaderAnnotations = true
+ )
+}
+
+private fun RecentFileItem.toSharedBookItem(
+ displayName: String,
+ includeReaderAnnotations: Boolean
+): SharedBookItem {
return SharedBookItem(
id = bookId,
path = uriString,
type = type,
- displayName = customName ?: displayName,
+ displayName = displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
@@ -53,13 +65,22 @@ fun RecentFileItem.toSharedBookItem(): SharedBookItem {
seriesName = seriesName,
seriesIndex = seriesIndex,
lastPageIndex = lastPage,
+ readerPosition = toSharedReaderLocatorOrNull(),
tags = tags.map { it.toSharedTag() },
- readerHighlights = EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
+ readerHighlights = if (includeReaderAnnotations) {
+ EpubAnnotationSerializer.parseHighlightsJson(highlightsJson)
+ } else {
+ emptyList()
+ },
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp
)
}
fun RecentFileItem.toSharedProjectionBookItem(): SharedBookItem {
- return toSharedBookItem().copy(displayName = displayName)
+ return toSharedBookItem(
+ displayName = displayName,
+ includeReaderAnnotations = false
+ )
}
fun SharedBookItem.toRecentFileItem(
@@ -67,36 +88,49 @@ fun SharedBookItem.toRecentFileItem(
tagEntitiesById: Map = emptyMap()
): RecentFileItem {
val resolvedTags = tags.map { tag -> tagEntitiesById[tag.id] ?: tag.toTagEntity(createdAt = 0L) }
- return androidBooksById[id]?.copy(tags = resolvedTags)
- ?.copy(
+ val positionCfi = readerPosition?.toSharedPositionCfi()
+ androidBooksById[id]?.let { existing ->
+ val mappedLastChapterIndex = readerPosition?.chapterIndex ?: existing.lastChapterIndex
+ val mappedLastPositionCfi = positionCfi ?: existing.lastPositionCfi
+ val mappedLocatorBlockIndex = readerPosition?.blockIndex ?: existing.locatorBlockIndex
+ val mappedLocatorCharOffset = readerPosition?.charOffset ?: existing.locatorCharOffset
+
+ if (
+ existing.uriString == path &&
+ existing.type == type &&
+ existing.timestamp == timestamp &&
+ existing.coverImagePath == coverImagePath &&
+ existing.title == title &&
+ existing.author == author &&
+ existing.description == description &&
+ existing.originalTitle == originalTitle &&
+ existing.originalAuthor == originalAuthor &&
+ existing.originalSeriesName == originalSeriesName &&
+ existing.originalSeriesIndex == originalSeriesIndex &&
+ existing.originalDescription == originalDescription &&
+ existing.lastPage == lastPageIndex &&
+ existing.progressPercentage == progressPercentage &&
+ existing.isRecent == isRecent &&
+ existing.sourceFolderUri == sourceFolder &&
+ existing.fileSize == fileSize &&
+ existing.fileContentModifiedTimestamp == fileContentModifiedTimestamp &&
+ existing.seriesName == seriesName &&
+ existing.seriesIndex == seriesIndex &&
+ existing.folderTextMetadataParsed == folderTextMetadataParsed &&
+ existing.lastChapterIndex == mappedLastChapterIndex &&
+ existing.lastPositionCfi == mappedLastPositionCfi &&
+ existing.locatorBlockIndex == mappedLocatorBlockIndex &&
+ existing.locatorCharOffset == mappedLocatorCharOffset &&
+ existing.readingPositionModifiedTimestamp == readingPositionModifiedTimestamp &&
+ existing.tags == resolvedTags
+ ) {
+ return existing
+ }
+
+ return existing.copy(
uriString = path,
type = type,
- displayName = androidBooksById[id]?.displayName ?: displayName,
- timestamp = timestamp,
- coverImagePath = coverImagePath,
- title = title,
- author = author,
- description = description,
- originalTitle = originalTitle,
- originalAuthor = originalAuthor,
- originalSeriesName = originalSeriesName,
- originalSeriesIndex = originalSeriesIndex,
- originalDescription = originalDescription,
- lastPage = lastPageIndex,
- progressPercentage = progressPercentage,
- isRecent = isRecent,
- sourceFolderUri = sourceFolder,
- fileSize = fileSize,
- fileContentModifiedTimestamp = fileContentModifiedTimestamp,
- seriesName = seriesName,
- seriesIndex = seriesIndex,
- folderTextMetadataParsed = folderTextMetadataParsed
- )
- ?: RecentFileItem(
- bookId = id,
- uriString = path,
- type = type,
- displayName = displayName,
+ displayName = existing.displayName,
timestamp = timestamp,
coverImagePath = coverImagePath,
title = title,
@@ -116,8 +150,70 @@ fun SharedBookItem.toRecentFileItem(
seriesName = seriesName,
seriesIndex = seriesIndex,
folderTextMetadataParsed = folderTextMetadataParsed,
+ lastChapterIndex = mappedLastChapterIndex,
+ lastPositionCfi = mappedLastPositionCfi,
+ locatorBlockIndex = mappedLocatorBlockIndex,
+ locatorCharOffset = mappedLocatorCharOffset,
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
tags = resolvedTags
)
+ }
+
+ return RecentFileItem(
+ bookId = id,
+ uriString = path,
+ type = type,
+ displayName = displayName,
+ timestamp = timestamp,
+ coverImagePath = coverImagePath,
+ title = title,
+ author = author,
+ description = description,
+ originalTitle = originalTitle,
+ originalAuthor = originalAuthor,
+ originalSeriesName = originalSeriesName,
+ originalSeriesIndex = originalSeriesIndex,
+ originalDescription = originalDescription,
+ lastPage = lastPageIndex,
+ progressPercentage = progressPercentage,
+ isRecent = isRecent,
+ sourceFolderUri = sourceFolder,
+ fileSize = fileSize,
+ fileContentModifiedTimestamp = fileContentModifiedTimestamp,
+ seriesName = seriesName,
+ seriesIndex = seriesIndex,
+ folderTextMetadataParsed = folderTextMetadataParsed,
+ lastChapterIndex = readerPosition?.chapterIndex,
+ lastPositionCfi = positionCfi,
+ locatorBlockIndex = readerPosition?.blockIndex,
+ locatorCharOffset = readerPosition?.charOffset,
+ readingPositionModifiedTimestamp = readingPositionModifiedTimestamp,
+ tags = resolvedTags
+ )
+}
+
+private fun RecentFileItem.toSharedReaderLocatorOrNull(): SharedReaderLocator? {
+ if (
+ lastChapterIndex == null &&
+ lastPage == null &&
+ lastPositionCfi.isNullOrBlank() &&
+ locatorBlockIndex == null &&
+ locatorCharOffset == null
+ ) {
+ return null
+ }
+ return SharedReaderLocator.fromLegacy(
+ chapterIndex = lastChapterIndex,
+ cfi = lastPositionCfi,
+ pageIndex = lastPage
+ ).withFallbacks(
+ blockIndex = locatorBlockIndex,
+ charOffset = locatorCharOffset
+ )
+}
+
+private fun SharedReaderLocator.toSharedPositionCfi(): String? {
+ return toStablePositionCfi()
}
fun TagEntity.toSharedTag(): SharedTag {
@@ -167,8 +263,16 @@ fun BookShelfCrossRef.toSharedBookShelfRef(): SharedBookShelfRef {
fun ReaderScreenState.toSharedReaderScreenState(
rawBooks: List = rawLibraryFiles,
- dbTags: List = allTags
+ dbTags: List = allTags,
+ includeReaderAnnotations: Boolean = true
): SharedReaderScreenState {
+ fun RecentFileItem.toStateSharedBookItem(): SharedBookItem {
+ return toSharedBookItem(
+ displayName = customName ?: displayName,
+ includeReaderAnnotations = includeReaderAnnotations
+ )
+ }
+
return SharedReaderScreenState(
selectedBookId = selectedBookId,
selectedUriString = selectedPdfUri?.toString() ?: selectedEpubUri?.toString(),
@@ -202,16 +306,16 @@ fun ReaderScreenState.toSharedReaderScreenState(
isSearchActive = isSearchActive,
isRefreshing = isRefreshing,
reflowProgress = reflowProgress,
- recentBooks = recentFiles.map { it.toSharedBookItem() },
- libraryBooks = allRecentFiles.map { it.toSharedBookItem() },
- rawLibraryBooks = rawBooks.map { it.toSharedBookItem() },
+ recentBooks = recentFiles.map { it.toStateSharedBookItem() },
+ libraryBooks = allRecentFiles.map { it.toStateSharedBookItem() },
+ rawLibraryBooks = rawBooks.map { it.toStateSharedBookItem() },
pinnedHomeBookIds = pinnedHomeBookIds,
pinnedLibraryBookIds = pinnedLibraryBookIds,
libraryFilters = libraryFilters,
recentFilesLimit = recentFilesLimit,
isTabsEnabled = isTabsEnabled,
openTabIds = openTabIds,
- openTabs = openTabs.map { it.toSharedBookItem() },
+ openTabs = openTabs.map { it.toStateSharedBookItem() },
activeTabBookId = activeTabBookId,
showExternalFileSavePromptFor = showExternalFileSavePromptFor,
externalFileBehavior = externalFileBehavior,
@@ -237,7 +341,14 @@ fun List.withResolvedTags(
val bookTagsMap = tagRefs.groupBy { it.bookId }.mapValues { entry ->
entry.value.mapNotNull { tagsById[it.tagId] }
}
- return map { item -> item.copy(tags = bookTagsMap[item.bookId].orEmpty()) }
+ return map { item ->
+ val resolvedTags = bookTagsMap[item.bookId].orEmpty()
+ if (item.tags == resolvedTags) {
+ item
+ } else {
+ item.copy(tags = resolvedTags)
+ }
+ }
}
fun SharedReaderScreenState.toAndroidReaderScreenState(
@@ -246,8 +357,11 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
tagEntitiesById: Map = emptyMap()
): ReaderScreenState {
val fallbackBooksById = rawLibraryBooks.associateBy { it.id }
+ val mappedBooksById = LinkedHashMap()
fun SharedBookItem.toAndroidBook(): RecentFileItem {
- return toRecentFileItem(androidBooksById, tagEntitiesById)
+ return mappedBooksById.getOrPut(id) {
+ toRecentFileItem(androidBooksById, tagEntitiesById)
+ }
}
fun bookById(bookId: String): RecentFileItem? {
return androidBooksById[bookId] ?: fallbackBooksById[bookId]?.toAndroidBook()
@@ -260,7 +374,7 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
isAddingBooksToShelf = isAddingBooksToShelf,
contextualActionShelfIds = selectedShelfIds,
contextualActionItems = selectedBookIds.mapNotNullTo(mutableSetOf()) { bookById(it) },
- shelves = shelves.map { it.toAndroidShelf(androidBooksById, tagEntitiesById) },
+ shelves = shelves.map { shelf -> shelf.toAndroidShelf { book -> book.toAndroidBook() } },
openTabs = openTabs.map { it.toAndroidBook() },
openTabIds = openTabIds,
activeTabBookId = activeTabBookId,
@@ -272,13 +386,19 @@ fun SharedReaderScreenState.toAndroidReaderScreenState(
fun SharedShelf.toAndroidShelf(
androidBooksById: Map = emptyMap(),
tagEntitiesById: Map = emptyMap()
+): Shelf {
+ return toAndroidShelf { it.toRecentFileItem(androidBooksById, tagEntitiesById) }
+}
+
+private fun SharedShelf.toAndroidShelf(
+ resolveBook: (SharedBookItem) -> RecentFileItem
): Shelf {
return Shelf(
id = id,
name = name,
type = type,
- books = books.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
- directBooks = directBooks.map { it.toRecentFileItem(androidBooksById, tagEntitiesById) },
+ books = books.map(resolveBook),
+ directBooks = directBooks.map(resolveBook),
parentShelfId = parentShelfId,
childShelfIds = childShelfIds,
depth = depth,
diff --git a/app/src/main/java/com/aryan/reader/SyncedFolderPrefs.kt b/app/src/main/java/com/aryan/reader/SyncedFolderPrefs.kt
new file mode 100644
index 0000000..db4b379
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/SyncedFolderPrefs.kt
@@ -0,0 +1,108 @@
+package com.aryan.reader
+
+import org.json.JSONArray
+import org.json.JSONObject
+import timber.log.Timber
+
+internal object SyncedFolderPrefs {
+ const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
+ const val KEY_LEGACY_SYNCED_FOLDER_URI = "synced_folder_uri"
+ const val KEY_LEGACY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
+
+ fun decodeSyncedFolders(
+ jsonString: String?,
+ legacyUri: String?,
+ legacyLastScanTime: Long = 0L,
+ legacyNameResolver: (String) -> String = { it },
+ syncableTypes: Set = ANDROID_SYNCABLE_FILE_TYPES
+ ): List {
+ if (jsonString == null) {
+ return legacyUri
+ ?.takeIf { it.isNotBlank() }
+ ?.let { uri ->
+ listOf(
+ SyncedFolder(
+ uriString = uri,
+ name = legacyNameResolver(uri),
+ lastScanTime = legacyLastScanTime,
+ allowedFileTypes = syncableTypes,
+ localSyncEnabled = true
+ )
+ )
+ }
+ .orEmpty()
+ }
+
+ return try {
+ val array = JSONArray(jsonString)
+ buildList {
+ for (i in 0 until array.length()) {
+ val obj = array.getJSONObject(i)
+ val uri = obj.optString("uri").takeIf { it.isNotBlank() }
+ if (uri == null) continue
+ val name = obj.optString("name").takeIf { it.isNotBlank() } ?: legacyNameResolver(uri)
+ add(
+ SyncedFolder(
+ uriString = uri,
+ name = name,
+ lastScanTime = obj.optLong("lastScanTime", 0L),
+ allowedFileTypes = decodeAllowedFileTypes(obj, syncableTypes),
+ localSyncEnabled = obj.optBoolean("localSyncEnabled", true)
+ )
+ )
+ }
+ }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to parse synced folders JSON")
+ emptyList()
+ }
+ }
+
+ fun encodeSyncedFolders(
+ folders: List,
+ syncableTypes: Set = ANDROID_SYNCABLE_FILE_TYPES
+ ): String {
+ val jsonArray = JSONArray()
+ folders.forEach { folder ->
+ val obj = JSONObject()
+ obj.put("uri", folder.uriString)
+ obj.put("name", folder.name)
+ obj.put("lastScanTime", folder.lastScanTime)
+ obj.put("localSyncEnabled", folder.localSyncEnabled)
+ val typesArray = JSONArray()
+ folder.allowedFileTypes
+ .filter { it in syncableTypes }
+ .forEach { typesArray.put(it.name) }
+ obj.put("allowedFileTypes", typesArray)
+ jsonArray.put(obj)
+ }
+ return jsonArray.toString()
+ }
+
+ fun isLocalSyncEnabled(
+ jsonString: String?,
+ legacyUri: String?,
+ folderUriString: String,
+ syncableTypes: Set = ANDROID_SYNCABLE_FILE_TYPES
+ ): Boolean {
+ return decodeSyncedFolders(
+ jsonString = jsonString,
+ legacyUri = legacyUri,
+ syncableTypes = syncableTypes
+ ).firstOrNull { it.uriString == folderUriString }?.localSyncEnabled == true
+ }
+
+ private fun decodeAllowedFileTypes(
+ obj: JSONObject,
+ syncableTypes: Set
+ ): Set {
+ if (!obj.has("allowedFileTypes")) return syncableTypes
+ val typesArray = obj.optJSONArray("allowedFileTypes") ?: return syncableTypes
+ return buildSet {
+ for (i in 0 until typesArray.length()) {
+ val type = runCatching { FileType.valueOf(typesArray.getString(i)) }.getOrNull()
+ if (type != null && type in syncableTypes) add(type)
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt
index 15a2799..485929c 100644
--- a/app/src/main/java/com/aryan/reader/data/AppDatabase.kt
+++ b/app/src/main/java/com/aryan/reader/data/AppDatabase.kt
@@ -36,7 +36,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
TagEntity::class,
BookTagCrossRef::class
],
- version = 22,
+ version = 23,
exportSchema = false
)
@TypeConverters(FileTypeConverter::class)
@@ -288,6 +288,25 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ val MIGRATION_22_23 = object : Migration(22, 23) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE recent_files ADD COLUMN readingPositionModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
+ db.execSQL("""
+ UPDATE recent_files
+ SET readingPositionModifiedTimestamp = lastModifiedTimestamp
+ WHERE lastModifiedTimestamp > 0
+ AND (
+ lastChapterIndex IS NOT NULL OR
+ lastPage IS NOT NULL OR
+ lastPositionCfi IS NOT NULL OR
+ locatorBlockIndex IS NOT NULL OR
+ locatorCharOffset IS NOT NULL OR
+ COALESCE(progressPercentage, 0) > 0
+ )
+ """)
+ }
+ }
+
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
@@ -301,7 +320,7 @@ abstract class AppDatabase : RoomDatabase() {
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12,
MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, MIGRATION_15_16,
MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, MIGRATION_19_20,
- MIGRATION_20_21, MIGRATION_21_22
+ MIGRATION_20_21, MIGRATION_21_22, MIGRATION_22_23
)
.fallbackToDestructiveMigration(false)
.build()
diff --git a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
index 889d5a1..b077d3b 100644
--- a/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
+++ b/app/src/main/java/com/aryan/reader/data/LocalSyncUtils.kt
@@ -8,6 +8,7 @@ import android.provider.DocumentsContract
import androidx.documentfile.provider.DocumentFile
import com.aryan.reader.ReaderPerfLog
import com.aryan.reader.shared.LOCAL_FOLDER_SIDECAR_HASH_PREFIX
+import com.aryan.reader.shared.LOCAL_FOLDER_SYNC_DATA_DIR
import com.aryan.reader.shared.localFolderSyncAnnotationFileName
import com.aryan.reader.shared.localFolderSyncAnnotationTempFileName
import com.aryan.reader.shared.localFolderSyncMetadataFileName
@@ -21,7 +22,7 @@ import timber.log.Timber
object LocalSyncUtils {
private const val TAG = "FolderSync"
private const val ANNOTATION_SUFFIX = "_annotations"
- private const val SYNC_SUBFOLDER_NAME = "EpistemeSyncData"
+ private const val SYNC_SUBFOLDER_NAME = LOCAL_FOLDER_SYNC_DATA_DIR
private data class SyncFileEntry(
val name: String,
@@ -638,6 +639,21 @@ object LocalSyncUtils {
}
}
+ suspend fun deleteSyncDataFolder(
+ context: Context,
+ sourceFolderUri: Uri
+ ): Boolean = withContext(Dispatchers.IO) {
+ try {
+ val rootTree = DocumentFile.fromTreeUri(context, sourceFolderUri) ?: return@withContext false
+ val syncDir = rootTree.findFile(SYNC_SUBFOLDER_NAME) ?: return@withContext true
+ if (!syncDir.isDirectory) return@withContext false
+ syncDir.delete()
+ } catch (e: Exception) {
+ Timber.tag(TAG).e(e, "Failed to delete sync data folder")
+ false
+ }
+ }
+
suspend fun getAllFolderMetadata(
context: Context,
sourceFolderUri: Uri
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
index 08cc5fb..be5dbd1 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
@@ -33,7 +33,7 @@ interface RecentFileDao {
@Upsert
suspend fun insertOrUpdateFiles(files: List)
- @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
+ @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow>
@Query("SELECT * FROM recent_files WHERE sourceFolderUri = :sourceFolderUri AND isDeleted = 0")
@@ -45,7 +45,7 @@ interface RecentFileDao {
@Query("UPDATE recent_files SET isReflowPreferred = :isPreferred WHERE bookId = :bookId")
suspend fun updateReflowPreference(bookId: String, isPreferred: Boolean)
- @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
+ @Query("SELECT bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent, isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset, sourceFolderUri, isReflowPreferred, customName, fileSize, fileContentModifiedTimestamp, seriesName, seriesIndex, description, originalTitle, originalAuthor, originalSeriesName, originalSeriesIndex, originalDescription, readingPositionModifiedTimestamp FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
@@ -75,10 +75,10 @@ interface RecentFileDao {
@Query("DELETE FROM recent_files")
suspend fun clearAll()
- @Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
+ @Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateEpubReadingPosition(bookId: String, cfi: String?, chapterIndex: Int, blockIndex: Int, charOffset: Int, progress: Float, timestamp: Long)
- @Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
+ @Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp, readingPositionModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updatePdfReadingPosition(bookId: String, page: Int, progress: Float, timestamp: Long)
@Query("UPDATE recent_files SET bookmarks = :bookmarksJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
index 26ace97..21be423 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
@@ -62,7 +62,8 @@ data class RecentFileEntity(
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
- @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
+ @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null,
+ @ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L
)
data class RecentFileSummary(
@@ -96,5 +97,6 @@ data class RecentFileSummary(
@ColumnInfo(defaultValue = "NULL") val originalAuthor: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesName: String? = null,
@ColumnInfo(defaultValue = "NULL") val originalSeriesIndex: Double? = null,
- @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null
+ @ColumnInfo(defaultValue = "NULL") val originalDescription: String? = null,
+ @ColumnInfo(defaultValue = "0") val readingPositionModifiedTimestamp: Long = 0L
)
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
index 6276f79..a080409 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
@@ -57,9 +57,25 @@ data class RecentFileItem(
val originalDescription: String? = null,
val folderTextMetadataParsed: Boolean = false,
val folderCoverMetadataParsed: Boolean = false,
+ val readingPositionModifiedTimestamp: Long = 0L,
val tags: List = emptyList()
)
+fun RecentFileItem.hasReadingPositionForSync(): Boolean {
+ return lastChapterIndex != null ||
+ lastPage != null ||
+ !lastPositionCfi.isNullOrBlank() ||
+ locatorBlockIndex != null ||
+ locatorCharOffset != null ||
+ (progressPercentage ?: 0f) > 0f
+}
+
+fun RecentFileItem.effectiveReadingPositionModifiedTimestamp(): Long {
+ return readingPositionModifiedTimestamp.takeIf { it > 0L }
+ ?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() }
+ ?: 0L
+}
+
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
@@ -96,7 +112,8 @@ fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
originalSeriesIndex = this.originalSeriesIndex,
originalDescription = this.originalDescription,
folderTextMetadataParsed = this.folderTextMetadataParsed,
- folderCoverMetadataParsed = this.folderCoverMetadataParsed
+ folderCoverMetadataParsed = this.folderCoverMetadataParsed,
+ readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp
)
}
@@ -136,7 +153,8 @@ fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
originalSeriesIndex = this.originalSeriesIndex ?: this.seriesIndex,
originalDescription = this.originalDescription ?: this.description,
folderTextMetadataParsed = this.folderTextMetadataParsed,
- folderCoverMetadataParsed = this.folderCoverMetadataParsed
+ folderCoverMetadataParsed = this.folderCoverMetadataParsed,
+ readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp()
)
}
@@ -156,6 +174,7 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
isRecent = this.isRecent,
isDeleted = this.isDeleted,
lastModifiedTimestamp = this.lastModifiedTimestamp,
+ readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp(),
bookmarksJson = this.bookmarksJson,
hasAnnotations = false,
customName = this.customName,
@@ -172,6 +191,27 @@ fun RecentFileItem.toBookMetadata(): BookMetadata {
)
}
+fun BookMetadata.hasReadingPositionForSync(): Boolean {
+ return lastChapterIndex != null ||
+ lastPage != null ||
+ !lastPositionCfi.isNullOrBlank() ||
+ locatorBlockIndex != null ||
+ locatorCharOffset != null ||
+ (progressPercentage ?: 0f) > 0f
+}
+
+fun BookMetadata.effectiveReadingPositionModifiedTimestamp(): Long {
+ return readingPositionModifiedTimestamp.takeIf { it > 0L }
+ ?: lastModifiedTimestamp.takeIf { hasReadingPositionForSync() }
+ ?: 0L
+}
+
+fun BookMetadata.effectiveAnnotationModifiedTimestamp(sidecarModifiedTimestamp: Long = 0L): Long {
+ return sidecarModifiedTimestamp.takeIf { it > 0L }
+ ?: annotationModifiedTimestamp.takeIf { it > 0L }
+ ?: 0L
+}
+
fun BookMetadata.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
@@ -203,7 +243,8 @@ fun BookMetadata.toRecentFileItem(): RecentFileItem {
originalAuthor = this.originalAuthor,
originalSeriesName = this.originalSeriesName,
originalSeriesIndex = this.originalSeriesIndex,
- originalDescription = this.originalDescription
+ originalDescription = this.originalDescription,
+ readingPositionModifiedTimestamp = this.effectiveReadingPositionModifiedTimestamp()
)
}
@@ -241,6 +282,7 @@ fun RecentFileSummary.toRecentFileItem(): RecentFileItem {
originalAuthor = this.originalAuthor,
originalSeriesName = this.originalSeriesName,
originalSeriesIndex = this.originalSeriesIndex,
- originalDescription = this.originalDescription
+ originalDescription = this.originalDescription,
+ readingPositionModifiedTimestamp = this.readingPositionModifiedTimestamp
)
}
diff --git a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
index 673485e..3fcdf45 100644
--- a/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
+++ b/app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
@@ -27,9 +27,15 @@ import android.net.Uri
import androidx.core.net.toUri
import com.aryan.reader.FileType
import com.aryan.reader.ReaderPerfLog
+import com.aryan.reader.SyncedFolderPrefs
+import com.aryan.reader.cloudSyncPreview
+import com.aryan.reader.cloudSyncTraceSummary
+import com.aryan.reader.logCloudAnnotationSyncTrace
+import com.aryan.reader.logCloudSyncTrace
import com.aryan.reader.scaledToCanvasLimit
import timber.log.Timber
import com.aryan.reader.BookImporter
+import com.aryan.reader.cloudSyncAnnotationSummary
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.pdf.PdfRichTextRepository
import com.aryan.reader.epub.ImportedFileCache
@@ -189,7 +195,20 @@ class RecentFilesRepository(private val context: Context) {
!embeddedMetadataFileChanged &&
existingItem.hasEmbeddedMetadataChanges()
- item.toRecentFileEntity().copy(
+ val incomingEntity = item.toRecentFileEntity()
+ val incomingReadingTimestamp = item.effectiveReadingPositionModifiedTimestamp()
+ val existingReadingTimestamp = existingItem.readingPositionModifiedTimestamp.takeIf { it > 0L }
+ ?: existingItem.lastModifiedTimestamp.takeIf {
+ existingItem.lastChapterIndex != null ||
+ existingItem.lastPage != null ||
+ !existingItem.lastPositionCfi.isNullOrBlank() ||
+ existingItem.locatorBlockIndex != null ||
+ existingItem.locatorCharOffset != null ||
+ (existingItem.progressPercentage ?: 0f) > 0f
+ }
+ ?: 0L
+ val incomingReadingWins = incomingReadingTimestamp >= existingReadingTimestamp
+ incomingEntity.copy(
uriString = existingItem.uriString ?: item.uriString,
isAvailable = existingItem.isAvailable || item.isAvailable,
coverImagePath = if (folderFileChanged) {
@@ -211,13 +230,13 @@ class RecentFilesRepository(private val context: Context) {
} else {
item.author ?: existingItem.author
},
- lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
- lastPage = item.lastPage ?: existingItem.lastPage,
- lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
- locatorBlockIndex = item.locatorBlockIndex ?: existingItem.locatorBlockIndex,
- locatorCharOffset = item.locatorCharOffset ?: existingItem.locatorCharOffset,
+ lastChapterIndex = if (incomingReadingWins) item.lastChapterIndex ?: existingItem.lastChapterIndex else existingItem.lastChapterIndex,
+ lastPage = if (incomingReadingWins) item.lastPage ?: existingItem.lastPage else existingItem.lastPage,
+ lastPositionCfi = if (incomingReadingWins) item.lastPositionCfi ?: existingItem.lastPositionCfi else existingItem.lastPositionCfi,
+ locatorBlockIndex = if (incomingReadingWins) item.locatorBlockIndex ?: existingItem.locatorBlockIndex else existingItem.locatorBlockIndex,
+ locatorCharOffset = if (incomingReadingWins) item.locatorCharOffset ?: existingItem.locatorCharOffset else existingItem.locatorCharOffset,
bookmarks = item.bookmarksJson ?: existingItem.bookmarks,
- progressPercentage = item.progressPercentage ?: existingItem.progressPercentage,
+ progressPercentage = if (incomingReadingWins) item.progressPercentage ?: existingItem.progressPercentage else existingItem.progressPercentage,
isRecent = item.isRecent,
isDeleted = item.isDeleted,
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri,
@@ -259,13 +278,26 @@ class RecentFilesRepository(private val context: Context) {
item.folderCoverMetadataParsed
} else {
item.folderCoverMetadataParsed || existingItem.folderCoverMetadataParsed
- }
+ },
+ readingPositionModifiedTimestamp = maxOf(incomingReadingTimestamp, existingReadingTimestamp)
)
} else {
item.toRecentFileEntity()
}
Timber.d("SyncDebug: -> Final entity to insert: uri='${entityToInsert.uriString}', isAvailable=${entityToInsert.isAvailable}, isDeleted=${entityToInsert.isDeleted}, isRecent=${entityToInsert.isRecent}")
+ logCloudSyncTrace {
+ "android.db.upsert book=${item.bookId} ${item.cloudSyncTraceSummary("incoming")} " +
+ "existingTs=${existingItem?.lastModifiedTimestamp} existingPage=${existingItem?.lastPage} " +
+ "existingReadTs=${existingItem?.readingPositionModifiedTimestamp} " +
+ "existingChapter=${existingItem?.lastChapterIndex} finalTs=${entityToInsert.lastModifiedTimestamp} " +
+ "finalReadTs=${entityToInsert.readingPositionModifiedTimestamp} " +
+ "finalPage=${entityToInsert.lastPage} finalChapter=${entityToInsert.lastChapterIndex} " +
+ "finalBlock=${entityToInsert.locatorBlockIndex} finalChar=${entityToInsert.locatorCharOffset} " +
+ "finalProgress=${entityToInsert.progressPercentage} finalCfi=${entityToInsert.lastPositionCfi.cloudSyncPreview()} " +
+ "finalBookmarks=${entityToInsert.bookmarks.cloudSyncAnnotationSummary()} " +
+ "finalHighlights=${entityToInsert.highlights.cloudSyncAnnotationSummary()}"
+ }
recentFileDao.insertOrUpdateFile(entityToInsert)
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
}
@@ -328,6 +360,11 @@ class RecentFilesRepository(private val context: Context) {
val folderUriString = entity.sourceFolderUri
if (folderUriString != null) {
+ if (!isLocalFolderSyncEnabled(folderUriString)) {
+ Timber.d("SyncDebug: Folder sync disabled for $folderUriString. Skipping metadata sidecar.")
+ return@withContext
+ }
+
val hasProgress = (entity.progressPercentage != null && entity.progressPercentage > 0f)
val hasBookmarks = !entity.bookmarks.isNullOrEmpty() && entity.bookmarks != "[]"
val hasHighlights = !entity.highlights.isNullOrEmpty() && entity.highlights != "[]"
@@ -385,6 +422,10 @@ class RecentFilesRepository(private val context: Context) {
Timber.tag("FolderAnnotationSync").w("sourceFolderUri is null for bookId: $bookId")
return@withContext
}
+ if (!isLocalFolderSyncEnabled(folderUriString)) {
+ Timber.tag("FolderAnnotationSync").d("Folder sync disabled for $folderUriString. Skipping annotation sidecar.")
+ return@withContext
+ }
val inkFile = pdfAnnotationRepository.getAnnotationFileForSync(bookId)
val richTextFile = pdfRichTextRepository.getFileForSync(bookId)
@@ -466,12 +507,20 @@ class RecentFilesRepository(private val context: Context) {
)
}
- suspend fun importAnnotationBundle(bookId: String, jsonString: String) = withContext(Dispatchers.IO) {
+ suspend fun importAnnotationBundle(
+ bookId: String,
+ jsonString: String,
+ lastModifiedTimestamp: Long? = null
+ ) = withContext(Dispatchers.IO) {
Timber.tag("FolderAnnotationSync").d("importAnnotationBundle: Processing bundle for $bookId")
try {
val bundle = JSONObject(
SharedPdfAnnotationSidecarCodec.legacyAndroidDataJsonFromCanonical(jsonString)
)
+ logCloudAnnotationSyncTrace {
+ "android.repository.import_bundle book=$bookId remoteTs=${lastModifiedTimestamp ?: 0L} " +
+ "rawBytes=${jsonString.length} keys=${bundle.keys().asSequence().toList()}"
+ }
Timber.d(
"android.folder.import.bundle book=$bookId rawLen=${jsonString.length} " +
"hasRichText=${bundle.has("text")} keys=${bundle.keys().asSequence().toList()}"
@@ -482,12 +531,22 @@ class RecentFilesRepository(private val context: Context) {
file.parentFile?.mkdirs()
val contentStr = bundle.get(key).toString()
file.writeText(contentStr)
+ lastModifiedTimestamp?.takeIf { it > 0L }?.let(file::setLastModified)
+ logCloudAnnotationSyncTrace {
+ "android.repository.import_write key=$key book=$bookId bytes=${contentStr.length} " +
+ "path=${file.absolutePath.cloudSyncPreview(140)} ts=${file.lastModified()}"
+ }
if (key == "text") {
Timber.d(
"android.folder.import.writeRichText book=$bookId rawLen=${contentStr.length} file=${file.absolutePath}"
)
}
Timber.tag("FolderAnnotationSync").v(" -> Updated $key file (${contentStr.length} chars)")
+ } else if (file != null) {
+ logCloudAnnotationSyncTrace {
+ "android.repository.import_missing_key key=$key book=$bookId " +
+ "path=${file.absolutePath.cloudSyncPreview(140)} exists=${file.exists()}"
+ }
}
}
@@ -496,6 +555,10 @@ class RecentFilesRepository(private val context: Context) {
context.filesDir, "annotations/annotation_$bookId.json"
)
writeSafe("ink", inkFile)
+ writeSafe(
+ SharedPdfAnnotationSidecarCodec.KEY_PDF_ANNOTATION_DELETIONS,
+ File(context.filesDir, "annotations/deleted_annotation_$bookId.json")
+ )
// 2. Text
writeSafe("text", pdfRichTextRepository.getFileForSync(bookId))
@@ -606,6 +669,15 @@ class RecentFilesRepository(private val context: Context) {
Timber.d("Detached all folder books. They are now standard local files.")
}
+ private fun isLocalFolderSyncEnabled(folderUriString: String): Boolean {
+ val prefs = context.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
+ return SyncedFolderPrefs.isLocalSyncEnabled(
+ jsonString = prefs.getString(SyncedFolderPrefs.KEY_SYNCED_FOLDERS_JSON, null),
+ legacyUri = prefs.getString(SyncedFolderPrefs.KEY_LEGACY_SYNCED_FOLDER_URI, null),
+ folderUriString = folderUriString
+ )
+ }
+
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
@@ -618,6 +690,9 @@ class RecentFilesRepository(private val context: Context) {
val currentTime = System.currentTimeMillis()
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
Timber.tag("PdfPositionDebug").i("Repository: Executed DB update for ${item.bookId} to Page $page, Progress $progress% at TS: $currentTime")
+ logCloudSyncTrace {
+ "android.repository.pdf_position_update book=${item.bookId} page=$page progress=$progress ts=$currentTime"
+ }
} else {
Timber.tag("PdfPositionDebug").e("Repository: DB Update Failed! No recent file found matching URI: $uriString")
}
diff --git a/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt b/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt
index 12a06e4..81a9bf9 100644
--- a/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt
+++ b/app/src/main/java/com/aryan/reader/epub/CalibreBundleExtractor.kt
@@ -15,8 +15,8 @@ import timber.log.Timber
import java.io.ByteArrayInputStream
import java.io.File
import java.io.FileOutputStream
+import java.io.IOException
import java.util.zip.ZipInputStream
-import javax.xml.parsers.DocumentBuilderFactory
data class CalibreBundleResult(
val internalBookUri: Uri,
@@ -89,7 +89,7 @@ object CalibreBundleExtractor {
if (tempBookFile != null && opfData != null && extractedType != null) {
val finalBookFile = bookImporter.createBookFile("$bookId.$ext")
- tempBookFile!!.renameTo(finalBookFile)
+ moveExtractedBook(tempBookFile!!, finalBookFile)
var coverPath: String? = null
if (coverBytes != null) {
@@ -106,7 +106,7 @@ object CalibreBundleExtractor {
var seriesIndex: Double? = null
try {
- val factory = DocumentBuilderFactory.newInstance()
+ val factory = secureDocumentBuilderFactory()
val builder = factory.newDocumentBuilder()
val document = builder.parse(ByteArrayInputStream(opfData!!.toByteArray(Charsets.UTF_8)))
val metadataNodes = document.getElementsByTagName("metadata")
@@ -159,8 +159,25 @@ object CalibreBundleExtractor {
} catch (e: Exception) {
Timber.e(e, "Failed to process zip bundle")
} finally {
- tempBookFile?.delete() // Cleanup if parsing failed midway
+ tempBookFile?.takeIf { it.exists() }?.delete()
}
return@withContext null
}
-}
\ No newline at end of file
+
+ private fun moveExtractedBook(tempBookFile: File, finalBookFile: File) {
+ finalBookFile.parentFile?.mkdirs()
+ if (finalBookFile.exists() && !finalBookFile.delete()) {
+ throw IOException("Could not replace existing book file: ${finalBookFile.absolutePath}")
+ }
+ if (tempBookFile.renameTo(finalBookFile)) return
+
+ tempBookFile.inputStream().use { input ->
+ FileOutputStream(finalBookFile).use { output ->
+ input.copyTo(output)
+ }
+ }
+ if (!finalBookFile.isFile) {
+ throw IOException("Could not move extracted book to: ${finalBookFile.absolutePath}")
+ }
+ }
+}
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt b/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt
index ad80edf..c451ca8 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt
+++ b/app/src/main/java/com/aryan/reader/epub/EpubChapter.kt
@@ -33,5 +33,10 @@ data class EpubChapter @OptIn(ExperimentalSerializationApi::class) constructor(
@ProtoNumber(5) val plainTextContent: String,
@ProtoNumber(6) val htmlContent: String,
@ProtoNumber(7) val depth: Int = 0,
- @ProtoNumber(8) val isInToc: Boolean = true
-)
\ No newline at end of file
+ @ProtoNumber(8) val isInToc: Boolean = true,
+ @ProtoNumber(9) val plainTextLength: Int = plainTextContent.length
+)
+
+fun EpubChapter.plainTextCharacterCount(): Int {
+ return maxOf(plainTextLength, plainTextContent.length)
+}
diff --git a/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt b/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt
index 9cffed3..ff23961 100644
--- a/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt
+++ b/app/src/main/java/com/aryan/reader/epub/EpubUtils.kt
@@ -23,22 +23,53 @@ import org.w3c.dom.Document
import org.w3c.dom.Element
import org.w3c.dom.Node
import org.w3c.dom.NodeList
+import java.io.File
import java.io.InputStream
+import javax.xml.XMLConstants
import javax.xml.parsers.DocumentBuilderFactory
fun parseXMLFile(inputSteam: InputStream): Document? =
- DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(inputSteam)
+ secureDocumentBuilderFactory().newDocumentBuilder().parse(inputSteam)
fun parseXMLFile(byteArray: ByteArray): Document? = parseXMLFile(byteArray.inputStream())
fun String.asFileName(): String = this.replace("/", "_")
+internal fun secureDocumentBuilderFactory(): DocumentBuilderFactory {
+ return DocumentBuilderFactory.newInstance().apply {
+ isNamespaceAware = false
+ setFeatureSafely(XMLConstants.FEATURE_SECURE_PROCESSING, true)
+ setFeatureSafely("http://apache.org/xml/features/disallow-doctype-decl", true)
+ setFeatureSafely("http://xml.org/sax/features/external-general-entities", false)
+ setFeatureSafely("http://xml.org/sax/features/external-parameter-entities", false)
+ setFeatureSafely("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
+ runCatching { isXIncludeAware = false }
+ runCatching { isExpandEntityReferences = false }
+ }
+}
+
+private fun DocumentBuilderFactory.setFeatureSafely(name: String, value: Boolean) {
+ runCatching { setFeature(name, value) }
+}
+
+internal fun safeFileInRoot(root: File, childPath: String): File? {
+ val rootFile = runCatching { root.canonicalFile }.getOrNull() ?: return null
+ val targetFile = runCatching { File(rootFile, childPath).canonicalFile }.getOrNull() ?: return null
+ return targetFile.takeIf { it.isInsideOrSame(rootFile) }
+}
+
+internal fun File.isInsideOrSame(root: File): Boolean {
+ val rootPath = runCatching { root.canonicalFile.path }.getOrNull() ?: return false
+ val targetPath = runCatching { canonicalFile.path }.getOrNull() ?: return false
+ return targetPath == rootPath || targetPath.startsWith(rootPath + File.separator)
+}
+
fun Document.selectFirstTag(tag: String): Node? = getElementsByTagName(tag).item(0)
fun Node.selectFirstChildTag(tag: String) = childElements.find { it.tagName == tag }
fun Node.selectChildTag(tag: String) = childElements.filter { it.tagName == tag }
fun Node.getAttributeValue(attribute: String): String? =
attributes?.getNamedItem(attribute)?.textContent
-val NodeList.elements get() = (0..length).asSequence().mapNotNull { item(it) as? Element }
+val NodeList.elements get() = (0 until length).asSequence().mapNotNull { item(it) as? Element }
val Node.childElements get() = childNodes.elements
diff --git a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt
index 458b37f..404467b 100644
--- a/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/Fb2Parser.kt
@@ -12,6 +12,7 @@ import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
+import java.security.MessageDigest
import java.util.zip.ZipInputStream
class Fb2Parser(private val context: Context) {
@@ -210,7 +211,12 @@ class Fb2Parser(private val context: Context) {
if (!inBody) {
if (coverImageId == null) coverImageId = id
} else {
- currentChapterHtml.append("
")
+ val safeImageName = safeResourceFileName(id)
+ if (safeImageName != null) {
+ currentChapterHtml.append("
")
+ } else {
+ Timber.w("Skipping unsafe FB2 image reference: $id")
+ }
}
}
}
@@ -220,12 +226,23 @@ class Fb2Parser(private val context: Context) {
val base64Data = parser.nextText()
try {
val bytes = Base64.decode(base64Data, Base64.DEFAULT)
+ val safeId = safeResourceFileName(id)
if (parseContent) {
- val imgFile = File(extractionDir, id)
- FileOutputStream(imgFile).use { it.write(bytes) }
+ if (safeId != null) {
+ val imgFile = safeFileInRoot(extractionDir, safeId)
+ if (imgFile != null) {
+ FileOutputStream(imgFile).use { it.write(bytes) }
+ } else {
+ Timber.w("Skipping unsafe FB2 binary path: $id")
+ }
+ } else {
+ Timber.w("Skipping unsafe FB2 binary id: $id")
+ }
}
- images.add(EpubImage(absPath = id))
+ if (safeId != null) {
+ images.add(EpubImage(absPath = safeId))
+ }
if (id == coverImageId || (coverImageId == null && id.contains("cover", ignoreCase = true))) {
coverBytes = bytes
@@ -326,4 +343,30 @@ class Fb2Parser(private val context: Context) {
}
}
}
+
+ private fun safeResourceFileName(id: String): String? {
+ val rawName = id.substringAfterLast('/').substringAfterLast('\\').trim()
+ if (rawName.isBlank() || rawName == "." || rawName == "..") return null
+
+ val extension = rawName.substringAfterLast('.', missingDelimiterValue = "")
+ .takeIf { it.isNotBlank() && it.length <= 12 }
+ ?.replace(Regex("[^A-Za-z0-9]"), "")
+ .orEmpty()
+ val baseName = rawName.substringBeforeLast('.', rawName)
+ .replace(Regex("[^A-Za-z0-9._-]+"), "_")
+ .trim('.', '_', '-')
+ .ifBlank { "image" }
+ .take(48)
+ val suffix = sha256Hex(id).take(12)
+ return if (extension.isBlank()) {
+ "${baseName}_$suffix"
+ } else {
+ "${baseName}_$suffix.$extension"
+ }
+ }
+
+ private fun sha256Hex(value: String): String {
+ val digest = MessageDigest.getInstance("SHA-256").digest(value.toByteArray(Charsets.UTF_8))
+ return digest.joinToString("") { "%02x".format(it) }
+ }
}
diff --git a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
index 4451f12..9956b5c 100644
--- a/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
+++ b/app/src/main/java/com/aryan/reader/epub/OdtParser.kt
@@ -184,9 +184,13 @@ class OdtParser(private val context: Context) {
"Thumbnails/thumbnail.png" -> coverBytes = zis.readBytes()
else -> {
if (entry.name !in ignoredFiles) {
- val extractedFile = File(extractionDir, entry.name)
- extractedFile.parentFile?.mkdirs()
- FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
+ val extractedFile = safeFileInRoot(extractionDir, entry.name)
+ if (extractedFile != null) {
+ extractedFile.parentFile?.mkdirs()
+ FileOutputStream(extractedFile).use { out -> zis.copyTo(out) }
+ } else {
+ Timber.w("Skipping unsafe ODT entry outside extraction root: ${entry.name}")
+ }
}
}
}
@@ -371,10 +375,18 @@ class OdtParser(private val context: Context) {
if (isFlat) {
try {
val bytes = Base64.decode(base64Builder.toString(), Base64.DEFAULT)
- val imgName = currentImageHref?.substringAfterLast("/") ?: "${UUID.randomUUID()}.png"
- val imgFile = File(extractionDir, imgName)
- FileOutputStream(imgFile).use { it.write(bytes) }
- currentChapterHtml.append("
")
+ val imgName = currentImageHref
+ ?.substringAfterLast("/")
+ ?.substringAfterLast("\\")
+ ?.takeIf { it.isNotBlank() }
+ ?: "${UUID.randomUUID()}.png"
+ val imgFile = safeFileInRoot(extractionDir, imgName)
+ if (imgFile != null) {
+ FileOutputStream(imgFile).use { it.write(bytes) }
+ currentChapterHtml.append("
")
+ } else {
+ Timber.w("Skipping unsafe FODT image path: $imgName")
+ }
} catch (e: Exception) {
Timber.e(e, "Failed to decode FODT image")
}
diff --git a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
index 0d6c679..e86a0d1 100644
--- a/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
+++ b/app/src/main/java/com/aryan/reader/epub/SingleFileImporter.kt
@@ -56,6 +56,8 @@ class SingleFileImporter(private val context: Context) {
private const val MAX_HTML_BUFFERED_LINE_CHARS = 128_000
private const val MAX_HTML_HEAD_SCAN_CHARS = 256_000
private const val MAX_HTML_INLINE_CSS_CHARS = 256_000
+ private const val MAX_SINGLE_FILE_METADATA_BYTES = 2L * 1024L * 1024L
+ private const val BOOK_METADATA_FILE = "book_metadata.json"
private const val PAGE_BREAK_MARKER = ""
}
@@ -68,6 +70,62 @@ class SingleFileImporter(private val context: Context) {
private val htmlOutputSettings = Document.OutputSettings().prettyPrint(false)
+ private fun metadataFile(extractionDir: File): File = File(extractionDir, BOOK_METADATA_FILE)
+
+ private fun EpubBook.lightweightSingleFileCache(): EpubBook {
+ val cacheChapters = chapters.map { chapter ->
+ chapter.copy(
+ plainTextContent = "",
+ htmlContent = ""
+ )
+ }
+ return copy(
+ coverImage = null,
+ chapters = cacheChapters,
+ chaptersForPagination = cacheChapters
+ )
+ }
+
+ private fun readCachedSingleFileBook(metadataFile: File, extractionDir: File, tag: String): EpubBook? {
+ if (!metadataFile.exists()) return null
+ if (metadataFile.length() > MAX_SINGLE_FILE_METADATA_BYTES) {
+ Timber.w(
+ "Ignoring oversized $tag metadata cache (${metadataFile.length()} bytes). " +
+ "The file will be reparsed with lightweight metadata."
+ )
+ runCatching { metadataFile.delete() }
+ return null
+ }
+
+ return try {
+ val decodedBook = jsonSerializer.decodeFromString(metadataFile.readText())
+ val cacheChapters = decodedBook.chapters.map { it.copy(htmlContent = "") }
+ decodedBook.copy(
+ chapters = cacheChapters,
+ chaptersForPagination = cacheChapters,
+ extractionBasePath = extractionDir.absolutePath
+ ).takeIf { it.hasReadableExtractedContent() }
+ } catch (e: OutOfMemoryError) {
+ Timber.e(e, "Failed to load cached $tag metadata without exhausting memory")
+ runCatching { metadataFile.delete() }
+ null
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to load cached $tag, parsing again")
+ null
+ }
+ }
+
+ private fun writeSingleFileMetadata(metadataFile: File, book: EpubBook, tag: String) {
+ try {
+ metadataFile.writeText(jsonSerializer.encodeToString(book.lightweightSingleFileCache()))
+ } catch (e: OutOfMemoryError) {
+ Timber.e(e, "Failed to cache lightweight $tag metadata without exhausting memory")
+ runCatching { metadataFile.delete() }
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to cache $tag metadata")
+ }
+ }
+
suspend fun importSingleFile(
inputStream: InputStream,
type: FileType,
@@ -195,17 +253,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
- val metadataFile = File(extractionDir, "book_metadata.json")
+ val metadataFile = metadataFile(extractionDir)
- if (metadataFile.exists()) {
- try {
- val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText())
- .copy(extractionBasePath = extractionDir.absolutePath)
- Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
- return@withContext cachedBook
- } catch (e: Exception) {
- Timber.e(e, "Failed to load cached MD, parsing again")
- }
+ readCachedSingleFileBook(metadataFile, extractionDir, "MD")?.let { cachedBook ->
+ Timber.tag("FileOpenPerf").d("[MD] Loaded from cache instantly | bookId=$bookId")
+ return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
@@ -299,11 +351,7 @@ class SingleFileImporter(private val context: Context) {
css = emptyMap()
)
- try {
- metadataFile.writeText(jsonSerializer.encodeToString(book))
- } catch (e: Exception) {
- Timber.e(e, "Failed to cache MD metadata")
- }
+ writeSingleFileMetadata(metadataFile, book, "MD")
return@withContext book
}
@@ -331,17 +379,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
- val metadataFile = File(extractionDir, "book_metadata.json")
+ val metadataFile = metadataFile(extractionDir)
- if (metadataFile.exists()) {
- try {
- val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText())
- .copy(extractionBasePath = extractionDir.absolutePath)
- Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
- return@withContext cachedBook
- } catch (e: Exception) {
- Timber.e(e, "Failed to load cached TXT, parsing again")
- }
+ readCachedSingleFileBook(metadataFile, extractionDir, "TXT")?.let { cachedBook ->
+ Timber.tag("FileOpenPerf").d("[TXT] Loaded from cache instantly | bookId=$bookId")
+ return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
@@ -462,11 +504,7 @@ class SingleFileImporter(private val context: Context) {
css = emptyMap()
)
- try {
- metadataFile.writeText(jsonSerializer.encodeToString(book))
- } catch (e: Exception) {
- Timber.e(e, "Failed to cache TXT metadata")
- }
+ writeSingleFileMetadata(metadataFile, book, "TXT")
return@withContext book
}
@@ -494,17 +532,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
- val metadataFile = File(extractionDir, "book_metadata.json")
+ val metadataFile = metadataFile(extractionDir)
- if (metadataFile.exists()) {
- try {
- val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText())
- .copy(extractionBasePath = extractionDir.absolutePath)
- Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
- return@withContext cachedBook
- } catch (e: Exception) {
- Timber.e(e, "Failed to load cached HTML, parsing again")
- }
+ readCachedSingleFileBook(metadataFile, extractionDir, "HTML")?.let { cachedBook ->
+ Timber.tag("FileOpenPerf").d("[HTML] Loaded from cache instantly | bookId=$bookId")
+ return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
@@ -699,11 +731,7 @@ class SingleFileImporter(private val context: Context) {
css = emptyMap()
)
- try {
- metadataFile.writeText(jsonSerializer.encodeToString(book))
- } catch (e: Exception) {
- Timber.e(e, "Failed to cache HTML metadata")
- }
+ writeSingleFileMetadata(metadataFile, book, "HTML")
return@withContext book
}
@@ -783,17 +811,11 @@ class SingleFileImporter(private val context: Context) {
}
val extractionDir = ImportedFileCache.ensureActiveBookDir(context, bookId)
- val metadataFile = File(extractionDir, "book_metadata.json")
+ val metadataFile = metadataFile(extractionDir)
- if (metadataFile.exists()) {
- try {
- val cachedBook = jsonSerializer.decodeFromString(metadataFile.readText())
- .copy(extractionBasePath = extractionDir.absolutePath)
- Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
- return@withContext cachedBook
- } catch (e: Exception) {
- Timber.e(e, "Failed to load cached DOCX, parsing again")
- }
+ readCachedSingleFileBook(metadataFile, extractionDir, "DOCX")?.let { cachedBook ->
+ Timber.tag("FileOpenPerf").d("[DOCX] Loaded from cache instantly | bookId=$bookId")
+ return@withContext cachedBook
}
ImportedFileCache.resetActiveBookDir(context, bookId)
diff --git a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
index 34001a3..84a7e93 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/ChapterWebView.kt
@@ -100,6 +100,7 @@ import java.io.InputStreamReader
private const val TAG_LINK_NAV = "LINK_NAV"
private const val TAG_VERTICAL_JITTER = "EpubVerticalJitter"
+private const val TAG_ANDROID_HIGHLIGHT_RENDER_DIAG = "AndroidHighlightRenderDiag"
private val READER_WEB_VIEW_JS_INTERFACES = arrayOf(
"PageInfoReporter",
"ProgressReporter",
@@ -386,6 +387,36 @@ private data class CustomMenuState(
val selectedColor: HighlightColor? = null
)
+internal fun highlightsJsonForWebView(userHighlights: List): String {
+ val jsonArray = org.json.JSONArray()
+ userHighlights.forEach { highlight ->
+ val obj = JSONObject()
+ obj.put("id", highlight.id)
+ obj.put("cfi", highlight.cfi)
+ obj.put("text", highlight.text)
+ obj.put("cssClass", highlight.color.cssClass)
+ obj.put("colorId", highlight.color.id)
+ obj.put("chapterIndex", highlight.chapterIndex)
+ obj.put(
+ "locator",
+ JSONObject().apply {
+ highlight.locator.chapterIndex?.let { put("chapterIndex", it) }
+ highlight.locator.chapterId?.let { put("chapterId", it) }
+ highlight.locator.href?.let { put("href", it) }
+ highlight.locator.pageIndex?.let { put("pageIndex", it) }
+ highlight.locator.startOffset?.let { put("startOffset", it) }
+ highlight.locator.endOffset?.let { put("endOffset", it) }
+ highlight.locator.blockIndex?.let { put("blockIndex", it) }
+ highlight.locator.charOffset?.let { put("charOffset", it) }
+ highlight.locator.textQuote?.let { put("textQuote", it) }
+ highlight.locator.cfi?.let { put("cfi", it) }
+ }
+ )
+ jsonArray.put(obj)
+ }
+ return jsonArray.toString()
+}
+
@Suppress("unused")
class AiJsBridge(
private val scope: CoroutineScope, private val onContentReady: suspend (String) -> Unit
@@ -526,17 +557,7 @@ fun ChapterWebView(
)
}
- val highlightsJson = remember(userHighlights) {
- val jsonArray = org.json.JSONArray()
- userHighlights.forEach { h ->
- val obj = JSONObject()
- obj.put("cfi", h.cfi)
- obj.put("text", h.text)
- obj.put("cssClass", h.color.cssClass)
- jsonArray.put(obj)
- }
- jsonArray.toString()
- }
+ val highlightsJson = remember(userHighlights) { highlightsJsonForWebView(userHighlights) }
if (showExternalLinkDialog != null) {
val urlToShow = showExternalLinkDialog!!
@@ -719,6 +740,11 @@ fun ChapterWebView(
)
}
+ message.startsWith("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG:") -> {
+ Timber.tag(TAG_ANDROID_HIGHLIGHT_RENDER_DIAG)
+ .d("JS -> ${message.substringAfter("$TAG_ANDROID_HIGHLIGHT_RENDER_DIAG: ")}")
+ }
+
message.startsWith("ReaderFontDiagnosis") -> {
Timber.d(
"JS -> ${message.substringAfter("ReaderFontDiagnosis: ")}"
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt
index 56fead4..3390303 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderAnnotations.kt
@@ -61,6 +61,7 @@ import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
@@ -71,6 +72,7 @@ import androidx.core.text.HtmlCompat
import com.aryan.reader.R
import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.shared.EpubAnnotationSerializer
+import com.aryan.reader.shared.ReaderLocator
private const val BOOKMARK_PREFS_NAME = "epub_reader_bookmarks"
@@ -155,14 +157,20 @@ fun processAndAddHighlight(
newText: String,
newColor: HighlightColor,
chapterIndex: Int,
- currentList: MutableList
+ currentList: MutableList,
+ locator: ReaderLocator = ReaderLocator.fromLegacy(
+ chapterIndex = chapterIndex,
+ cfi = newCfi,
+ textQuote = newText
+ )
): String {
return EpubAnnotationSerializer.processAndAddHighlight(
newCfi = newCfi,
newText = newText,
newColor = newColor,
chapterIndex = chapterIndex,
- currentList = currentList
+ currentList = currentList,
+ locator = locator
)
}
@@ -593,6 +601,7 @@ fun HighlightColorRow(
modifier = Modifier
.padding(horizontal = 4.dp)
.size(28.dp)
+ .testTag("HighlightColor_${colorEnum.id}")
.clip(CircleShape) // 1. Clip shape for ripple
.background(colorEnum.color) // 2. Apply background
.clickable {
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt
index 0e9d8c6..e67e4e8 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderContent.kt
@@ -21,15 +21,17 @@ package com.aryan.reader.epubreader
import android.content.Context
import com.aryan.reader.R
-import timber.log.Timber
+import com.aryan.reader.applyBookReplacementsToHtmlDocument
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.contentFilePath
import com.aryan.reader.paginatedreader.LocatorConverter
+import com.aryan.reader.shared.ReaderBookReplacementPreferences
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jsoup.Jsoup
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
+import timber.log.Timber
import java.io.File
data class ChapterLoadingResult(
@@ -86,7 +88,9 @@ suspend fun loadChapterContent(
chunkTargetOverride: Int?,
isInitialCfiLoad: Boolean,
cfiToLoad: String?,
- locatorConverter: LocatorConverter
+ locatorConverter: LocatorConverter,
+ bookReplacementPreferences: ReaderBookReplacementPreferences = ReaderBookReplacementPreferences(),
+ bookReplacementFileId: String? = null,
): ChapterLoadingResult = withContext(Dispatchers.IO) {
val chapter =
epubBook.chapters.getOrNull(chapterIndex) ?: return@withContext ChapterLoadingResult(
@@ -100,6 +104,11 @@ suspend fun loadChapterContent(
val doc = Jsoup.parse(htmlFile, "UTF-8")
val head = doc.head().html()
doc.select("script").remove()
+ applyBookReplacementsToHtmlDocument(
+ document = doc,
+ preferences = bookReplacementPreferences,
+ fileId = bookReplacementFileId,
+ )
val bodyNodes = doc.body().childNodes().toList()
val htmlChunks = splitBodyNodesIntoReaderChunks(bodyNodes)
if (htmlChunks.isEmpty()) {
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
index c3799da..09ffe2c 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderControls.kt
@@ -1,30 +1,6 @@
-/*
- * Episteme Reader - A native Android document reader.
- * Copyright (C) 2026 Episteme
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as
- * published by the Free Software Foundation, either version 3 of the
- * License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- *
- * mail: epistemereader@gmail.com
- */
-// EpubReaderControls.kt
package com.aryan.reader.epubreader
-import android.annotation.SuppressLint
-import android.graphics.Bitmap
-import android.graphics.Canvas
import android.os.Build
-import android.webkit.WebView
import androidx.annotation.RequiresApi
import androidx.annotation.StringRes
import androidx.compose.foundation.lazy.LazyListState
@@ -38,14 +14,12 @@ import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.BorderStroke
-import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -56,6 +30,7 @@ import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
@@ -72,6 +47,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ArrowForward
+import androidx.compose.material.icons.automirrored.filled.NavigateBefore
+import androidx.compose.material.icons.automirrored.filled.NavigateNext
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.ArrowUpward
@@ -82,6 +59,10 @@ import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.GraphicEq
import androidx.compose.material.icons.filled.Info
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.KeyboardArrowUp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Pause
@@ -121,9 +102,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer
-import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
@@ -136,7 +115,6 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.zIndex
-import androidx.core.graphics.createBitmap
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.BuildConfig
import com.aryan.reader.R
@@ -145,15 +123,13 @@ import com.aryan.reader.SearchState
import com.aryan.reader.SearchTopBar
import com.aryan.reader.TooltipIconButton
import com.aryan.reader.areReaderAiFeaturesEnabled
-import com.aryan.reader.epub.EpubChapter
import com.aryan.reader.loadNativeVoice
-import com.aryan.reader.paginatedreader.BookPaginator
-import com.aryan.reader.paginatedreader.IPaginator
+import com.aryan.reader.readerSliderStepPage
+import com.aryan.reader.shared.ui.ReaderMinimalSlider
import com.aryan.reader.tts.GEMINI_TTS_SPEAKERS
+import com.aryan.reader.tts.ReaderTtsOverlaySize
import com.aryan.reader.tts.TtsPlaybackManager.TtsState
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import timber.log.Timber
+import com.aryan.reader.tts.formatReaderTtsChunkLabel
import kotlin.math.roundToInt
enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
@@ -177,7 +153,8 @@ enum class ReaderTool(@StringRes val titleRes: Int, val category: String) {
SCREEN_ORIENTATION(R.string.menu_screen_orientation, "Top Bar"),
AUTO_SCROLL(R.string.menu_auto_scroll, "Overflow Menu"),
TTS_SETTINGS(R.string.menu_tts_settings, "Overflow Menu"),
- TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu")
+ TTS_REPLACEMENTS(R.string.menu_tts_word_replacements, "Overflow Menu"),
+ BOOK_REPLACEMENTS(R.string.menu_book_word_replacements, "Overflow Menu")
}
enum class FlatItemType { SECTION_HEADER, TOOL, EMPTY_PLACEHOLDER, MORE_HEADER, MORE_TOOL }
@@ -286,6 +263,7 @@ internal enum class EpubOverflowMenuSection {
KEEP_SCREEN_ON,
VISUAL_OPTIONS,
AUTO_SCROLL,
+ BOOK_REPLACEMENTS,
TTS_SETTINGS,
FILE_INFO
}
@@ -309,6 +287,7 @@ internal fun epubOverflowMenuSections(
if (!hiddenTools.contains(ReaderTool.KEEP_SCREEN_ON.name)) add(EpubOverflowMenuSection.KEEP_SCREEN_ON)
if (!hiddenTools.contains(ReaderTool.VISUAL_OPTIONS.name)) add(EpubOverflowMenuSection.VISUAL_OPTIONS)
if (!hiddenTools.contains(ReaderTool.AUTO_SCROLL.name)) add(EpubOverflowMenuSection.AUTO_SCROLL)
+ if (!hiddenTools.contains(ReaderTool.BOOK_REPLACEMENTS.name)) add(EpubOverflowMenuSection.BOOK_REPLACEMENTS)
if (
!hiddenTools.contains(ReaderTool.TTS_SETTINGS.name) ||
!hiddenTools.contains(ReaderTool.TTS_REPLACEMENTS.name)
@@ -381,11 +360,13 @@ fun EpubReaderTopBar(
volumeScrollEnabled: Boolean,
isPageTurnAnimationEnabled: Boolean,
isRightToLeftPagination: Boolean,
+ useNativeVerticalRenderer: Boolean,
onNavigateBack: () -> Unit,
isKeepScreenOn: Boolean,
onToggleKeepScreenOn: (Boolean) -> Unit,
onCloseSearch: () -> Unit,
onChangeRenderMode: (RenderMode) -> Unit,
+ onUseNativeVerticalRendererChange: (Boolean) -> Unit,
onToggleBookmark: () -> Unit,
onToggleTapToNavigate: (Boolean) -> Unit,
onToggleVolumeScroll: (Boolean) -> Unit,
@@ -394,6 +375,7 @@ fun EpubReaderTopBar(
onStartAutoScroll: () -> Unit,
onOpenTtsSettings: () -> Unit,
onOpenTtsReplacements: () -> Unit,
+ onOpenBookReplacements: () -> Unit,
onOpenDictionarySettings: () -> Unit,
onOpenThemeSettings: () -> Unit,
onOpenBrightness: () -> Unit,
@@ -701,14 +683,29 @@ fun EpubReaderTopBar(
)
if (showReadingModeExpanded) {
DropdownMenuItem(
- text = { Text(stringResource(R.string.menu_reading_mode_vertical)) },
+ text = { Text(stringResource(R.string.menu_reading_mode_vertical_webview)) },
enabled = !isTtsActive,
onClick = {
+ onUseNativeVerticalRendererChange(false)
showMoreMenu = false
onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
},
trailingIcon = {
- if (currentRenderMode == RenderMode.VERTICAL_SCROLL) Icon(
+ if (currentRenderMode == RenderMode.VERTICAL_SCROLL && !useNativeVerticalRenderer) Icon(
+ Icons.Default.Check,
+ contentDescription = stringResource(R.string.content_desc_selected)
+ )
+ })
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_reading_mode_vertical_native)) },
+ enabled = !isTtsActive,
+ onClick = {
+ onUseNativeVerticalRendererChange(true)
+ showMoreMenu = false
+ onChangeRenderMode(RenderMode.VERTICAL_SCROLL)
+ },
+ trailingIcon = {
+ if (currentRenderMode == RenderMode.VERTICAL_SCROLL && useNativeVerticalRenderer) Icon(
Icons.Default.Check,
contentDescription = stringResource(R.string.content_desc_selected)
)
@@ -849,6 +846,22 @@ fun EpubReaderTopBar(
onStartAutoScroll()
})
}
+ EpubOverflowMenuSection.BOOK_REPLACEMENTS -> {
+ DropdownMenuItem(
+ text = { Text(stringResource(R.string.menu_book_word_replacements)) },
+ onClick = {
+ showMoreMenu = false
+ onOpenBookReplacements()
+ },
+ leadingIcon = {
+ Icon(
+ painter = painterResource(id = R.drawable.text_fields),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp)
+ )
+ }
+ )
+ }
EpubOverflowMenuSection.TTS_SETTINGS -> {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_tts_settings)) },
@@ -1158,26 +1171,18 @@ fun EpubReaderBottomBar(
}
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
-@SuppressLint("UnusedBoxWithConstraintsScope")
-@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun EpubReaderPageSlider(
isVisible: Boolean,
- currentRenderMode: RenderMode,
totalPages: Int,
sliderCurrentPage: Float,
sliderStartPage: Int,
- startPageThumbnail: Bitmap?,
- paginator: IPaginator?,
- chapters: List,
onScrub: (Float) -> Unit,
onJumpToPage: (Int) -> Unit,
modifier: Modifier = Modifier,
activeColor: Color = Color.Unspecified,
inactiveColor: Color = Color.Unspecified,
- contentColor: Color = Color.Unspecified,
- thumbnailSurfaceColor: Color = Color.Unspecified,
- thumbnailContentColor: Color = Color.Unspecified
+ contentColor: Color = Color.Unspecified
) {
val effectiveActiveColor = if (activeColor == Color.Unspecified) {
MaterialTheme.colorScheme.primary
@@ -1194,16 +1199,8 @@ fun EpubReaderPageSlider(
} else {
contentColor
}
- val effectiveThumbnailSurfaceColor = if (thumbnailSurfaceColor == Color.Unspecified) {
- MaterialTheme.colorScheme.surfaceVariant
- } else {
- thumbnailSurfaceColor
- }
- val effectiveThumbnailContentColor = if (thumbnailContentColor == Color.Unspecified) {
- MaterialTheme.colorScheme.onSurfaceVariant
- } else {
- thumbnailContentColor
- }
+ val maxPage = totalPages.coerceAtLeast(1)
+ val currentPage = sliderCurrentPage.roundToInt().coerceIn(1, maxPage)
AnimatedVisibility(
visible = isVisible,
@@ -1211,128 +1208,73 @@ fun EpubReaderPageSlider(
exit = slideOutVertically(animationSpec = tween(200)) { fullHeight -> fullHeight } + fadeOut(animationSpec = tween(200)),
modifier = modifier
) {
- Column(modifier = Modifier.fillMaxWidth()) {
- Spacer(Modifier.height(72.dp))
- Box(
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
+ ) {
+ Row(
modifier = Modifier
.fillMaxWidth()
- .clickable(indication = null, interactionSource = remember { MutableInteractionSource() }) {},
+ .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
+ .padding(horizontal = 12.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
- Row(
- modifier = Modifier
- .fillMaxWidth()
- .windowInsetsPadding(WindowInsets.navigationBars.only(WindowInsetsSides.Horizontal))
- .padding(horizontal = 32.dp, vertical = 14.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(16.dp)
- ) {
- BoxWithConstraints(
- modifier = Modifier.weight(1f),
- contentAlignment = Alignment.Center
- ) {
- Slider(
- value = sliderCurrentPage,
- onValueChange = onScrub,
- valueRange = 1f..(totalPages.toFloat().coerceAtLeast(1f)),
- steps = if (totalPages > 2) totalPages - 2 else 0,
- modifier = Modifier.fillMaxWidth(),
- thumb = {
- Surface(
- modifier = Modifier.size(20.dp),
- shape = CircleShape,
- color = effectiveActiveColor,
- tonalElevation = 0.dp,
- shadowElevation = 0.dp
- ) {}
- },
- track = { sliderState ->
- val trackHeight = 2.dp
- val trackShape = RoundedCornerShape(trackHeight)
- val range = sliderState.valueRange.endInclusive - sliderState.valueRange.start
- val fraction = if (range == 0f) 0f else {
- ((sliderState.value - sliderState.valueRange.start) / range).coerceIn(0f, 1f)
- }
- Box(
- modifier = Modifier
- .fillMaxWidth()
- .height(trackHeight)
- .background(
- color = effectiveInactiveColor,
- shape = trackShape
- )
- ) {
- Box(
- modifier = Modifier
- .fillMaxWidth(fraction)
- .fillMaxHeight()
- .background(
- color = effectiveActiveColor,
- shape = trackShape
- )
- )
- }
- }
- )
-
- // Thumbnail Indicator
- val startPageOffsetFraction = if (totalPages > 1) {
- (sliderStartPage - 1).toFloat() / (totalPages - 1)
- } else {
- 0f
- }
- val thumbWidth = 20.dp
- val trackWidth = maxWidth - thumbWidth
- val startPagePixelPosition = (trackWidth * startPageOffsetFraction) + (thumbWidth / 2)
- val thumbnailModifier = Modifier
- .graphicsLayer { clip = false }
- .align(Alignment.TopStart)
- .offset(
- x = startPagePixelPosition - (45.dp / 2),
- y = (-72).dp
+ IconButton(
+ onClick = {
+ onJumpToPage(
+ readerSliderStepPage(
+ currentPage = currentPage,
+ delta = -1,
+ minPage = 1,
+ maxPage = maxPage
)
+ )
+ },
+ enabled = currentPage > 1,
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.NavigateBefore,
+ contentDescription = stringResource(R.string.desktop_previous_page),
+ tint = effectiveContentColor.copy(alpha = if (currentPage > 1) 0.9f else 0.32f)
+ )
+ }
- if (currentRenderMode == RenderMode.VERTICAL_SCROLL) {
- startPageThumbnail?.let { thumbnail ->
- ThumbnailWithIndicator(
- modifier = thumbnailModifier,
- borderColor = effectiveActiveColor,
- onClick = { onJumpToPage(sliderStartPage) }
- ) {
- Image(
- bitmap = thumbnail.asImageBitmap(),
- contentDescription = stringResource(R.string.content_desc_start_page_thumbnail),
- contentScale = ContentScale.FillBounds,
- modifier = Modifier.fillMaxSize()
- )
- }
- }
- } else {
- val startPageChapterIndex = remember(sliderStartPage, paginator) {
- (paginator as? BookPaginator)?.findChapterIndexForPage(sliderStartPage - 1)
- }
- val startPageChapterTitle = remember(startPageChapterIndex) {
- startPageChapterIndex?.let { chapters.getOrNull(it)?.title }
- }
- ThumbnailWithIndicator(
- modifier = thumbnailModifier,
- borderColor = effectiveActiveColor,
- onClick = { onJumpToPage(sliderStartPage) }
- ) {
- PaginatedThumbnailContent(
- pageNumber = sliderStartPage,
- chapterTitle = startPageChapterTitle,
- surfaceColor = effectiveThumbnailSurfaceColor,
- contentColor = effectiveThumbnailContentColor
- )
- }
- }
- }
+ ReaderMinimalSlider(
+ value = sliderCurrentPage.coerceIn(1f, maxPage.toFloat()),
+ onValueChange = onScrub,
+ valueRange = 1f..maxPage.toFloat(),
+ enabled = maxPage > 1,
+ activeColor = effectiveActiveColor,
+ inactiveColor = effectiveInactiveColor,
+ thumbColor = effectiveActiveColor,
+ markerValue = sliderStartPage.toFloat(),
+ markerColor = effectiveActiveColor,
+ modifier = Modifier
+ .weight(1f)
+ .height(32.dp)
+ )
- Text(
- text = "${sliderCurrentPage.roundToInt()} / $totalPages",
- style = MaterialTheme.typography.bodyLarge,
- color = effectiveContentColor,
- fontSize = 18.sp
+ IconButton(
+ onClick = {
+ onJumpToPage(
+ readerSliderStepPage(
+ currentPage = currentPage,
+ delta = 1,
+ minPage = 1,
+ maxPage = maxPage
+ )
+ )
+ },
+ enabled = currentPage < maxPage,
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ Icons.AutoMirrored.Filled.NavigateNext,
+ contentDescription = stringResource(R.string.desktop_next_page),
+ tint = effectiveContentColor.copy(alpha = if (currentPage < maxPage) 0.9f else 0.32f)
)
}
}
@@ -1375,109 +1317,6 @@ fun PageScrubbingAnimation(currentPage: Int, totalPages: Int) {
}
}
-@Composable
-internal fun ThumbnailWithIndicator(
- modifier: Modifier = Modifier,
- borderColor: Color = Color.Unspecified,
- onClick: () -> Unit,
- content: @Composable () -> Unit
-) {
- val effectiveBorderColor = if (borderColor == Color.Unspecified) {
- MaterialTheme.colorScheme.primary
- } else {
- borderColor
- }
- Column(
- modifier = modifier,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- Surface(
- modifier = Modifier
- .width(45.dp)
- .height(64.dp)
- .clickable(onClick = onClick),
- shape = RoundedCornerShape(4.dp),
- border = BorderStroke(2.dp, effectiveBorderColor)
- ) {
- content()
- }
- Box(
- modifier = Modifier
- .offset(y = (-4).dp)
- .size(8.dp)
- .rotate(45f)
- .background(effectiveBorderColor)
- )
- }
-}
-
-@Composable
-private fun PaginatedThumbnailContent(
- pageNumber: Int,
- chapterTitle: String?,
- surfaceColor: Color = Color.Unspecified,
- contentColor: Color = Color.Unspecified
-) {
- val effectiveSurfaceColor = if (surfaceColor == Color.Unspecified) {
- MaterialTheme.colorScheme.surfaceVariant
- } else {
- surfaceColor
- }
- val effectiveContentColor = if (contentColor == Color.Unspecified) {
- MaterialTheme.colorScheme.onSurfaceVariant
- } else {
- contentColor
- }
- Surface(
- modifier = Modifier.fillMaxSize(),
- color = effectiveSurfaceColor,
- contentColor = effectiveContentColor
- ) {
- Column(
- modifier = Modifier.padding(4.dp),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally
- ) {
- if (chapterTitle != null) {
- Text(
- text = chapterTitle,
- style = MaterialTheme.typography.labelSmall,
- maxLines = 3,
- overflow = TextOverflow.Ellipsis,
- textAlign = TextAlign.Center,
- lineHeight = 10.sp
- )
- Spacer(modifier = Modifier.height(4.dp))
- }
- Text(
- text = "$pageNumber",
- style = MaterialTheme.typography.bodyLarge,
- fontWeight = FontWeight.Bold
- )
- }
- }
-}
-
-suspend fun captureWebViewVisibleArea(webView: WebView): Bitmap? {
- return withContext(Dispatchers.Main) {
- if (webView.width <= 0 || webView.height <= 0) return@withContext null
- try {
- val thumbnailWidth = 180
- val thumbnailHeight = 256
- val bitmap = createBitmap(thumbnailWidth, thumbnailHeight)
- val canvas = Canvas(bitmap)
- val scale = thumbnailWidth.toFloat() / webView.width.toFloat()
- canvas.scale(scale, scale)
- canvas.translate(-webView.scrollX.toFloat(), -webView.scrollY.toFloat())
- webView.draw(canvas)
- bitmap
- } catch (e: Exception) {
- Timber.e(e, "Failed to capture webview content")
- null
- }
- }
-}
-
@Composable
fun SpeedDropdown(
label: String,
@@ -2204,6 +2043,7 @@ private fun ToolPreviewIcon(tool: ReaderTool, isSliderActive: Boolean = false) {
ReaderTool.SEARCH -> Icon(Icons.Default.Search, contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.AI_FEATURES -> Icon(painterResource(id = R.drawable.ai), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.TTS_CONTROLS -> Icon(painterResource(id = R.drawable.text_to_speech), contentDescription = title, modifier = Modifier.size(20.dp))
+ ReaderTool.BOOK_REPLACEMENTS -> Icon(painterResource(id = R.drawable.text_fields), contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.FILE_INFO -> Icon(Icons.Default.Info, contentDescription = title, modifier = Modifier.size(20.dp))
ReaderTool.SCREEN_ORIENTATION -> Icon(Icons.Default.ScreenRotation, contentDescription = title, modifier = Modifier.size(20.dp))
else -> Icon(Icons.Default.MoreVert, contentDescription = title, modifier = Modifier.size(20.dp))
@@ -2260,8 +2100,8 @@ fun TtsOverlayControls(
ttsController: com.aryan.reader.tts.TtsController,
ttsState: TtsState,
currentTtsMode: com.aryan.reader.tts.TtsPlaybackManager.TtsMode,
- isCollapsed: Boolean,
- onCollapseChange: (Boolean) -> Unit,
+ overlaySize: ReaderTtsOverlaySize,
+ onOverlaySizeChange: (ReaderTtsOverlaySize) -> Unit,
onLocateCurrentChunk: () -> Unit,
onOpenTtsSettings: () -> Unit,
onClose: () -> Unit,
@@ -2301,11 +2141,17 @@ fun TtsOverlayControls(
}
}
val chunkLabel = remember(ttsState.currentChunkIndex, ttsState.totalChunks) {
- if (ttsState.currentChunkIndex >= 0 && ttsState.totalChunks > 0) {
- "Chunk ${ttsState.currentChunkIndex + 1}/${ttsState.totalChunks}"
- } else {
- null
- }
+ formatReaderTtsChunkLabel(ttsState.currentChunkIndex, ttsState.totalChunks)
+ }
+ val miniBarTitle = ttsState.bookTitle
+ ?.takeIf { it.isNotBlank() }
+ ?: stringResource(R.string.action_read_aloud)
+ val miniBarSubtitle = remember(chapterLabel, chunkLabel, progressPercent, miniBarTitle) {
+ listOfNotNull(
+ chunkLabel,
+ progressPercent?.let { "$it%" },
+ chapterLabel?.takeIf { it != miniBarTitle }
+ ).joinToString(" - ")
}
val canSkipPreviousChunk = !ttsState.isLoading &&
ttsState.currentChunkIndex > 0 &&
@@ -2330,24 +2176,40 @@ fun TtsOverlayControls(
tonalElevation = 0.dp,
shadowElevation = 0.dp,
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.8f)),
- modifier = modifier.widthIn(max = 400.dp).animateContentSize()
+ modifier = modifier
+ .widthIn(max = if (overlaySize == ReaderTtsOverlaySize.MEDIUM) 560.dp else 400.dp)
+ .animateContentSize()
) {
AnimatedContent(
- targetState = isCollapsed,
+ targetState = overlaySize,
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "TtsOverlayUnified"
- ) { collapsed ->
- if (collapsed) {
+ ) { size ->
+ if (size == ReaderTtsOverlaySize.SMALL) {
Row(
modifier = Modifier.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
IconButton(
- onClick = { onCollapseChange(false) },
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) },
modifier = Modifier.size(36.dp)
) {
- Icon(Icons.Default.ChevronLeft, "Expand", tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ Icon(
+ Icons.Default.KeyboardArrowUp,
+ stringResource(R.string.content_desc_expand),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) },
+ modifier = Modifier.size(36.dp)
+ ) {
+ Icon(
+ Icons.Default.KeyboardArrowLeft,
+ stringResource(R.string.content_desc_expand),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
Box(modifier = Modifier.size(36.dp), contentAlignment = Alignment.Center) {
FilledIconButton(
@@ -2371,6 +2233,114 @@ fun TtsOverlayControls(
)
}
}
+ } else if (size == ReaderTtsOverlaySize.MEDIUM) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(min = 64.dp)
+ .padding(horizontal = 8.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .clip(RoundedCornerShape(16.dp))
+ .clickable(onClick = onLocateCurrentChunk)
+ .padding(horizontal = 10.dp, vertical = 6.dp),
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = miniBarTitle,
+ style = MaterialTheme.typography.labelLarge,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ if (miniBarSubtitle.isNotBlank()) {
+ Text(
+ text = miniBarSubtitle,
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ Spacer(Modifier.width(4.dp))
+
+ IconButton(
+ enabled = canSkipPreviousChunk,
+ onClick = { ttsController.skipToPreviousChunk() },
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.SkipPrevious,
+ contentDescription = stringResource(R.string.content_desc_tts_previous_chunk),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Box(modifier = Modifier.size(48.dp), contentAlignment = Alignment.Center) {
+ FilledIconButton(
+ onClick = { if (ttsState.isPlaying) ttsController.pause() else ttsController.resume() },
+ modifier = Modifier.size(44.dp),
+ colors = IconButtonDefaults.filledIconButtonColors(
+ containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.2f),
+ contentColor = MaterialTheme.colorScheme.primary
+ )
+ ) {
+ Icon(
+ painterResource(if (ttsState.isPlaying) R.drawable.pause else R.drawable.play),
+ stringResource(R.string.content_desc_play_pause),
+ modifier = Modifier.size(22.dp)
+ )
+ }
+ if (ttsState.isLoading) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(48.dp),
+ color = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
+ strokeWidth = 2.dp
+ )
+ }
+ }
+
+ IconButton(
+ enabled = canSkipNextChunk,
+ onClick = { ttsController.skipToNextChunk() },
+ modifier = Modifier.size(40.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.SkipNext,
+ contentDescription = stringResource(R.string.content_desc_tts_next_chunk),
+ modifier = Modifier.size(24.dp)
+ )
+ }
+
+ Row(horizontalArrangement = Arrangement.spacedBy(0.dp)) {
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.LARGE) },
+ modifier = Modifier.size(34.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowUp,
+ contentDescription = stringResource(R.string.content_desc_expand),
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) },
+ modifier = Modifier.size(34.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowRight,
+ contentDescription = stringResource(R.string.content_desc_collapse),
+ modifier = Modifier.size(22.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
} else {
Column(modifier = Modifier.padding(16.dp)) {
Row(
@@ -2378,7 +2348,10 @@ fun TtsOverlayControls(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
- Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Row(
+ modifier = Modifier.weight(1f),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
shape = RoundedCornerShape(8.dp)
@@ -2428,6 +2401,8 @@ fun TtsOverlayControls(
}
}
+ Spacer(Modifier.width(8.dp))
+
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
IconButton(onClick = onLocateCurrentChunk, modifier = Modifier.size(32.dp)) {
Icon(
@@ -2437,8 +2412,27 @@ fun TtsOverlayControls(
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
- IconButton(onClick = { onCollapseChange(true) }, modifier = Modifier.size(32.dp)) {
- Icon(Icons.Default.ChevronRight, stringResource(R.string.content_desc_collapse), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.MEDIUM) },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ Icons.Default.KeyboardArrowDown,
+ stringResource(R.string.content_desc_collapse),
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ IconButton(
+ onClick = { onOverlaySizeChange(ReaderTtsOverlaySize.SMALL) },
+ modifier = Modifier.size(32.dp)
+ ) {
+ Icon(
+ Icons.Default.KeyboardArrowRight,
+ stringResource(R.string.content_desc_collapse),
+ modifier = Modifier.size(18.dp),
+ tint = MaterialTheme.colorScheme.onSurfaceVariant
+ )
}
IconButton(onClick = onClose, modifier = Modifier.size(32.dp)) {
Icon(Icons.Default.Close, stringResource(R.string.content_desc_stop_tts), tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(18.dp))
diff --git a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
index 3f8996e..96e7f55 100644
--- a/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
+++ b/app/src/main/java/com/aryan/reader/epubreader/EpubReaderScreen.kt
@@ -31,7 +31,6 @@ import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
-import android.graphics.Bitmap
import android.media.AudioManager
import android.net.Uri
import android.os.Build
@@ -155,6 +154,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.util.UnstableApi
import com.aryan.reader.AiDefinitionResult
import com.aryan.reader.BuildConfig
+import com.aryan.reader.BookWordReplacementsSheet
import com.aryan.reader.BuiltInThemes
import com.aryan.reader.MainViewModel
import com.aryan.reader.R
@@ -176,9 +176,11 @@ import com.aryan.reader.isByokCloudTtsAvailable
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
import com.aryan.reader.epub.hasReadableExtractedContent
+import com.aryan.reader.epub.plainTextCharacterCount
import com.aryan.reader.fetchAiDefinition
import com.aryan.reader.loadCustomThemes
import com.aryan.reader.loadGlobalTextureTransparency
+import com.aryan.reader.loadBookReplacementPreferences
import com.aryan.reader.loadReaderBrightnessSettings
import com.aryan.reader.loadReaderScreenOrientationMode
import com.aryan.reader.loadEpubRightToLeftPagination
@@ -195,16 +197,20 @@ import com.aryan.reader.paginatedreader.IPaginator
import com.aryan.reader.paginatedreader.ListItemBlock
import com.aryan.reader.paginatedreader.Locator
import com.aryan.reader.paginatedreader.LocatorConverter
+import com.aryan.reader.paginatedreader.NativeVerticalLocation
+import com.aryan.reader.paginatedreader.NativeVerticalReaderScreen
import com.aryan.reader.paginatedreader.PaginatedReaderScreen
import com.aryan.reader.paginatedreader.ParagraphBlock
import com.aryan.reader.paginatedreader.QuoteBlock
import com.aryan.reader.paginatedreader.TextContentBlock
import com.aryan.reader.paginatedreader.TtsChunk
import com.aryan.reader.paginatedreader.data.BookCacheDatabase
+import com.aryan.reader.paginatedreader.nativeVerticalProgressForCompatPage
import com.aryan.reader.paginatedreader.semanticBlockModule
import com.aryan.reader.rememberSearchState
import com.aryan.reader.saveCustomThemes
import com.aryan.reader.saveGlobalTextureTransparency
+import com.aryan.reader.saveBookReplacementPreferences
import com.aryan.reader.saveReaderBrightnessSettings
import com.aryan.reader.saveReaderScreenOrientationMode
import com.aryan.reader.saveEpubRightToLeftPagination
@@ -212,11 +218,16 @@ import com.aryan.reader.saveReaderThemeId
import com.aryan.reader.saveReaderSliderToggled
import com.aryan.reader.saveTtsReplacementPreferences
import com.aryan.reader.shouldRenderReaderSlider
+import com.aryan.reader.shared.ReaderBookReplacementPreferences
import com.aryan.reader.shared.ReaderTtsReplacementPreferences
import com.aryan.reader.shared.ReaderLocator as SharedReaderLocator
+import com.aryan.reader.tts.ReaderTtsOverlaySize
import com.aryan.reader.tts.SpeakerSamplePlayer
import com.aryan.reader.tts.TtsPlaybackManager
import com.aryan.reader.tts.loadTtsMode
+import com.aryan.reader.tts.loadReaderTtsOverlaySize
+import com.aryan.reader.tts.readerTtsOverlayAlignmentBias
+import com.aryan.reader.tts.saveReaderTtsOverlaySize
import com.aryan.reader.tts.splitTextIntoChunks
import com.aryan.reader.withTtsReplacements
import com.aryan.reader.shared.reader.ReaderJumpHistory
@@ -238,6 +249,7 @@ import timber.log.Timber
import java.io.File
import kotlin.math.ceil
import kotlin.math.floor
+import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
@@ -553,7 +565,7 @@ fun EpubReaderScreen(
coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit,
customFonts: List,
- onImportFont: (Uri) -> Unit,
+ onImportFonts: (List) -> Unit,
viewModel: MainViewModel
) {
val uiState by viewModel.uiState.collectAsState()
@@ -635,7 +647,7 @@ fun EpubReaderScreen(
coverImagePath = coverImagePath,
onRenderModeChange = onRenderModeChange,
customFonts = customFonts,
- onImportFont = onImportFont,
+ onImportFonts = onImportFonts,
onToggleReflow = onOpenOriginal,
onDeleteReflow = if (isReflowFile) {
{
@@ -674,7 +686,7 @@ fun EpubReaderHost(
coverImagePath: String?,
onRenderModeChange: (RenderMode) -> Unit,
customFonts: List,
- onImportFont: (Uri) -> Unit,
+ onImportFonts: (List) -> Unit,
onToggleReflow: ((Int) -> Unit)? = null,
onDeleteReflow: (() -> Unit)? = null,
stableBookId: String? = null,
@@ -718,7 +730,6 @@ fun EpubReaderHost(
val scrubDebounceJob = remember { mutableStateOf(null) }
val volumeScrollFocusDebounceJob = remember { mutableStateOf(null) }
var sliderStartPage by remember { mutableIntStateOf(0) }
- var startPageThumbnail by remember { mutableStateOf(null) }
var pendingNoteForNewHighlight by remember { mutableStateOf(false) }
var highlightToNoteCfi by remember { mutableStateOf(null) }
@@ -800,7 +811,7 @@ fun EpubReaderHost(
}
var isAutoScrollCollapsed by remember { mutableStateOf(false) }
- var isTtsCollapsed by remember { mutableStateOf(false) }
+ var ttsOverlaySize by remember(context) { mutableStateOf(loadReaderTtsOverlaySize(context)) }
var isAutoScrollLocal by remember { mutableStateOf(loadAutoScrollLocalMode(context, bookId)) }
@@ -994,12 +1005,18 @@ fun EpubReaderHost(
var showRecapPopup by remember { mutableStateOf(false) }
var currentRenderMode by remember(renderMode) { mutableStateOf(renderMode) }
+ var useNativeVerticalRenderer by remember { mutableStateOf(loadNativeVerticalRenderer(context)) }
+ val isNativeVerticalMode = currentRenderMode == RenderMode.VERTICAL_SCROLL && useNativeVerticalRenderer
var epubJumpHistory by remember(readerCacheBookId) { mutableStateOf(ReaderJumpHistory()) }
var chapterToLoadOnSwitch by remember { mutableStateOf(null) }
var lastKnownLocator by remember(initialLocator) { mutableStateOf(initialLocator) }
var paginatedReconfigurationAnchor by remember { mutableStateOf(null) }
var isPaginatedReconfigurationRestoring by remember { mutableStateOf(false) }
+ LaunchedEffect(useNativeVerticalRenderer) {
+ saveNativeVerticalRenderer(context, useNativeVerticalRenderer)
+ }
+
val bottomPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
val roundedCornerBottomPadding = rememberBottomRoundedCornerPadding(view)
val pageInfoCornerBottomPadding = roundedCornerBottomPadding.coerceAtMost(8.dp)
@@ -1099,7 +1116,7 @@ fun EpubReaderHost(
val ttsState by ttsController.ttsState.collectAsState()
val totalBookLengthChars = remember(chapters) {
- chapters.sumOf { it.plainTextContent.length.toLong() }
+ chapters.sumOf { it.plainTextCharacterCount().toLong() }
}
var topVisibleChunkIndex by remember { mutableIntStateOf(0) }
@@ -1117,6 +1134,7 @@ fun EpubReaderHost(
var imageToLoad by remember { mutableStateOf(null) }
var isInitialCfiLoad by remember(initialLocator) { mutableStateOf(initialLocator != null) }
var bookmarkPageMap by remember { mutableStateOf