Folder import\sync rework (#18)

* feat: rework Folder Sync architecture to separate Managed vs Linked books

- Introduced "Managed" (imported to app storage) vs "Linked" (SAF URI) book logic.
- Disabled Google Drive/Metadata synchronization for Folder-Linked books to respect privacy and storage.
- Updated deletion logic: Folder books are now marked as deleted in the DB to blacklist them from future auto-scans, while Managed books are permanently purged from local and cloud storage.
- Enhanced folder sync discoverability by adding a direct "Sync Folder" navigation button in Home screen.
- Implemented reactive pager navigation in MainScreen and LibraryScreen via LaunchedEffect to allow programmatic tab switching from the ViewModel.

* Implemented a local folder synchronization system to track and update book metadata across devices.

Key changes:
- Added `FolderBookMetadata` and `LocalSyncUtils` for serializing and managing metadata files in a `.episteme` directory.
- Implemented bidirectional sync in `FolderSyncWorker` to reconcile local database state with folder-based metadata using timestamps and Syncthing conflict resolution.
- Updated `MainViewModel` to trigger folder metadata sync when closing a book and verify for remote updates upon opening.
- Enhanced folder management with manual scan support via `WorkManager` and automatic cleanup of database entries when a folder is disconnected.
- Added `RecentFileDao` methods to support bulk deletion and prefix-based file lookups.

* Removed API 34 restriction for chapter processing and updated local folder sync directory name

* Increased TTS timeouts, added edge-to-edge support in MainScreen, and improved TtsChunk mapping safety

* Improved folder synchronization and metadata management.

- Added metadata-only sync option and triggered it on app start.
- Implemented hidden metadata filenames (prefixed with `.`) to reduce file clutter.
- Added automatic creation of `.nomedia` files in the sync directory.
- Enhanced sync conflict resolution by picking the latest metadata and cleaning up obsolete or orphaned files.
- Added "Sync Metadata" button to the Library screen.
- Updated UI labels for local folder sync to clarify Google Drive integration.
- Refactored `FolderSyncWorker` to support targeted metadata-only synchronization.

* Implement folder sync migration and refactoring

- Added a migration dialog to inform users about the folder sync refactor, where books are now read directly from external storage.
- Updated `MainViewModel` to handle migration state and trigger a full scan upon completion or dismissal of the dialog.
- Modified `FolderSyncWorker` to support legacy book matching during migration, updating file URIs and cleaning up internal app storage for migrated books.
- Improved synchronization logic between local and remote metadata, including cleanup of orphaned metadata files.
- Refined `RecentFileItem` updates during sync to preserve progress and bookmarks based on last modified timestamps.

* Implemented pull-to-refresh for library sync and enhanced folder file deletion.

- Added `isRefreshing` state to `MainViewModel` and integrated `PullToRefreshBox` in `HomeScreen`.
- Implemented `refreshLibrary` to trigger cloud and folder metadata synchronization.
- Updated deletion logic to physically remove files and metadata from synced local folders.
- Added a warning to the delete confirmation dialog when removing folder-synced items.
- Improved folder sync reliability by performing lazy cleanup of missing files during interaction and sync.
- Fixed a bug where sync loading indicators would not retract on failure or cancellation.

* Improved bookmark navigation and scroll synchronization in EPUB reader

- Refined bookmark navigation logic for vertical scroll mode to handle chunk injection more reliably.
- Added `isNavigatingToBookmark` state to show a loading overlay during long jumps.
- Implemented `onScrollFinished` callback in `CfiJsBridge` to synchronize UI state with WebView scroll completion.
- Updated `ChapterWebView` to use `rememberUpdatedState` for JavaScript bridge callbacks to ensure data consistency.
- Improved `getCurrentCfi` and `scrollToCfi` in `epub_reader.js` with better visibility probing and retry logic for detached nodes.

* Implemented background metadata extraction for folder sync.

Key changes:
- Added `MetadataExtractionWorker` to handle heavy metadata and cover extraction for files in the background.
- Refactored `FolderSyncWorker` to perform fast file discovery using placeholders, enqueuing the metadata worker upon completion.
- Added `getFolderBooksWithoutCovers` query to `RecentFileDao`.
- Updated UI components (`EmptyState`, `MainViewModel`) to improve user feedback during sync and provide clear setup actions.
- Enhanced `EmptyState` composable to support secondary actions and custom button text.

* Updated Library Screen UI
This commit is contained in:
Aryan 2026-03-01 22:57:53 +05:30 committed by GitHub
parent 60f6566b31
commit a81df6921d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 3378 additions and 2478 deletions

View file

@ -31,7 +31,6 @@ import android.graphics.Bitmap
import android.media.AudioManager
import android.net.Uri
import android.os.Build
import timber.log.Timber
import android.webkit.WebView
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
@ -77,10 +76,8 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
@ -103,6 +100,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.BiasAlignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.focus.FocusRequester
@ -117,6 +115,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
@ -131,7 +130,6 @@ import com.aryan.reader.RenderMode
import com.aryan.reader.SearchResult
import com.aryan.reader.SummarizationResult
import com.aryan.reader.SummaryCacheManager
import com.aryan.reader.SyncUpdateInfo
import com.aryan.reader.countWords
import com.aryan.reader.data.CustomFontEntity
import com.aryan.reader.epub.EpubBook
@ -165,14 +163,13 @@ import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.protobuf.ProtoBuf
import org.json.JSONArray
import org.json.JSONObject
import timber.log.Timber
import java.io.File
import kotlin.math.ceil
import kotlin.math.floor
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import androidx.compose.ui.BiasAlignment
import androidx.core.content.edit
private const val AUTO_SCROLL_LOCKED_KEY = "auto_scroll_locked"
private const val AUTO_SCROLL_USE_SLIDER_KEY = "auto_scroll_use_slider"
@ -206,8 +203,6 @@ fun EpubReaderScreen(
initialCfi: String?,
initialBookmarksJson: String?,
isProUser: Boolean,
pendingSyncUpdate: SyncUpdateInfo?,
onClearPendingSyncUpdate: () -> Unit,
onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -230,8 +225,6 @@ fun EpubReaderScreen(
onNavigateToPro = onNavigateToPro,
coverImagePath = coverImagePath,
onRenderModeChange = onRenderModeChange,
pendingSyncUpdate = pendingSyncUpdate,
onClearPendingSyncUpdate = onClearPendingSyncUpdate,
customFonts = customFonts,
onImportFont = onImportFont
)
@ -249,8 +242,6 @@ fun EpubReaderHost(
initialCfi: String?,
initialBookmarksJson: String?,
isProUser: Boolean,
pendingSyncUpdate: SyncUpdateInfo?,
onClearPendingSyncUpdate: () -> Unit,
onNavigateBack: () -> Unit,
onSavePosition: (locator: Locator, cfiForWebView: String?, progress: Float) -> Unit,
onBookmarksChanged: (bookmarksJson: String) -> Unit,
@ -269,6 +260,7 @@ fun EpubReaderHost(
val focusManager = LocalFocusManager.current
val searchFocusRequester = remember { FocusRequester() }
val containerFocusRequester = remember { FocusRequester() }
var isNavigatingToBookmark by remember { mutableStateOf(false) }
var isPageSliderVisible by remember { mutableStateOf(false) }
var sliderCurrentPage by remember { mutableFloatStateOf(0f) }
@ -559,62 +551,6 @@ fun EpubReaderHost(
}
}
LaunchedEffect(pendingSyncUpdate) {
if (pendingSyncUpdate != null) {
val locator = pendingSyncUpdate.locator
val message = if (locator != null) {
val chapterTitle = chapters.getOrNull(locator.chapterIndex)?.title ?: "another location"
"Newer reading position found in '$chapterTitle'. Sync now?"
} else {
"Bookmarks updated on another device. Sync now?"
}
val result = withTimeoutOrNull(10_000L) {
snackbarHostState.showSnackbar(
message = message,
actionLabel = "Sync",
withDismissAction = true,
duration = SnackbarDuration.Indefinite
)
}
if (result == SnackbarResult.ActionPerformed) {
if (locator != null) {
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
val cfi = locatorConverter.getCfiFromLocator(epubBook.title, locator)
if (cfi != null) {
val targetChunk = locator.blockIndex / 20
if (currentChapterIndex != locator.chapterIndex) {
chunkTargetOverride = targetChunk
currentChapterIndex = locator.chapterIndex
} else {
if (targetChunk >= loadedChunkCount) {
loadUpToChunkIndex = targetChunk
}
}
cfiToLoad = cfi
} else {
Timber.w("Could not get CFI from locator for sync.")
}
}
RenderMode.PAGINATED -> {
(paginator as? BookPaginator)?.findPageForLocator(locator)?.let { page ->
scope.launch {
paginatedPagerState.scrollToPage(page)
}
}
}
}
}
pendingSyncUpdate.bookmarksJson?.let { newBookmarksJson ->
bookmarks = loadBookmarks(context, epubBook.title, chapters, newBookmarksJson)
}
}
onClearPendingSyncUpdate()
}
}
LaunchedEffect(skipChapterRequest) {
if (skipChapterRequest) {
skipChapterRequest = false
@ -1261,32 +1197,95 @@ fun EpubReaderHost(
when (currentRenderMode) {
RenderMode.VERTICAL_SCROLL -> {
Timber.tag("BookmarkDiagnosis").d("Navigating to ${bookmark.cfi}")
cfiToLoad = bookmark.cfi
val locator = locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
val targetChunk = locator?.let { it.blockIndex / 20 }
// FIX: Try to extract chunk index directly from CFI for Vertical Mode
// Vertical Mode CFIs are relative to content-container, so the first number
// usually represents the chunk (2->Chunk0, 4->Chunk1, 6->Chunk2...)
val directChunkIndex = try {
val parts = bookmark.cfi.split('/').mapNotNull { it.toIntOrNull() }
if (parts.isNotEmpty()) {
val firstIndex = parts[0]
// Standard EPUB CFI: indices are 1-based steps (2, 4, 6...)
(firstIndex - 2) / 2
} else null
} catch (e: Exception) {
null
}
val locator = if (directChunkIndex == null) {
locatorConverter.getLocatorFromCfi(epubBook, bookmark.chapterIndex, bookmark.cfi)
} else {
null
}
val targetChunk = directChunkIndex ?: locator?.let { it.blockIndex / 20 }
if (bookmark.chapterIndex != currentChapterIndex) {
if (targetChunk != null) {
chunkTargetOverride = targetChunk
chunkTargetOverride = if (targetChunk != null && targetChunk >= 0) {
targetChunk
} else {
chunkTargetOverride = 0
Timber.w("Could not get locator for bookmark CFI, will navigate to start of chapter.")
0
}
currentChapterIndex = bookmark.chapterIndex
} else {
if (targetChunk != null) {
}
else {
if (targetChunk != null && targetChunk >= 0) {
isNavigatingToBookmark = true
// FIX: Ensure we don't reload if we already have it,
// but do ensure the WebView has the content injected.
if (targetChunk >= loadedChunkCount) {
Timber.tag("BookmarkDiagnosis").d("Manual Chunk Injection: Loading from $loadedChunkCount to $targetChunk")
val chunksToInject = (loadedChunkCount..targetChunk)
chunksToInject.forEach { idx ->
val content = chapterChunks.getOrNull(idx)
if (content != null) {
val escaped = escapeJsString(content)
webViewRefForTts?.evaluateJavascript(
"javascript:window.virtualization.appendChunk($idx, '$escaped');",
null
)
}
}
loadUpToChunkIndex = targetChunk
loadedChunkCount = max(loadedChunkCount, targetChunk + 1)
} else {
webViewRefForTts?.evaluateJavascript("javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');", null)
// Even if loadedChunkCount is high enough in Kotlin state,
// ensure the specific chunk for the bookmark is actually in the DOM.
// (Sometimes rapid jumps might leave gaps if logic was loose)
val content = chapterChunks.getOrNull(targetChunk)
if (content != null) {
val escaped = escapeJsString(content)
webViewRefForTts?.evaluateJavascript(
"javascript:window.virtualization.appendChunk($targetChunk, '$escaped');",
null
)
}
}
webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');",
null
)
scope.launch {
delay(3000)
if (isNavigatingToBookmark) {
isNavigatingToBookmark = false
}
}
} else {
Timber.w("Could not get locator for bookmark CFI in current chapter, loading all chunks as fallback.")
loadUpToChunkIndex = if (chapterChunks.isNotEmpty()) chapterChunks.size - 1 else 0
// Fallback if we couldn't determine chunk
webViewRefForTts?.evaluateJavascript(
"javascript:window.scrollToCfi('${escapeJsString(bookmark.cfi)}');",
null
)
}
}
}
RenderMode.PAGINATED -> {
Timber.d("P-Mode Click: Navigating to bookmark. Chapter: ${bookmark.chapterIndex}, CFI: '${bookmark.cfi}'")
val locator = locatorConverter.getLocatorFromCfi(
@ -1588,7 +1587,7 @@ fun EpubReaderHost(
"ControlFlowWithEmptyBody"
)
ChapterWebView(
key = "$chapterKeyForWebView-$loadUpToChunkIndex",
key = "$chapterKeyForWebView",
chapterTitle = chapterToRender.title,
isDarkTheme = isDarkTheme,
initialScrollTarget = initialScrollTargetForChapter,
@ -1795,6 +1794,10 @@ fun EpubReaderHost(
null
)
},
onScrollFinished = { success ->
Timber.tag("BookmarkDiagnosis").d("Scroll finished callback. Success: $success")
isNavigatingToBookmark = false
},
ttsScope = scope,
onTtsTextReady = { jsonString ->
scope.launch {
@ -2959,6 +2962,26 @@ fun EpubReaderHost(
isTtsSessionActive = isTtsSessionActive
)
if (isNavigatingToBookmark) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.6f))
.clickable(enabled = true) {},
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Navigating to bookmark...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onBackground
)
}
}
}
if (showPermissionRationaleDialog) {
AlertDialog(
onDismissRequest = { showPermissionRationaleDialog = false },