* 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
259 lines
No EOL
12 KiB
Kotlin
259 lines
No EOL
12 KiB
Kotlin
/*
|
|
* 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 <https://www.gnu.org/licenses/>.
|
|
*
|
|
* mail: epistemereader@gmail.com
|
|
*/
|
|
package com.aryan.reader
|
|
|
|
import android.os.Build
|
|
import timber.log.Timber
|
|
import androidx.annotation.RequiresApi
|
|
import androidx.compose.foundation.layout.Arrangement
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.Column
|
|
import androidx.compose.foundation.layout.Spacer
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.foundation.layout.padding
|
|
import androidx.compose.material3.Button
|
|
import androidx.compose.material3.CircularProgressIndicator
|
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.material3.windowsizeclass.WindowSizeClass
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|
import androidx.navigation.NavHostController
|
|
import androidx.navigation.compose.NavHost
|
|
import androidx.navigation.compose.composable
|
|
import com.aryan.reader.epubreader.EpubReaderScreen
|
|
import com.aryan.reader.feedback.FeedbackScreen
|
|
import com.aryan.reader.pdf.PdfViewerScreen
|
|
|
|
object AppDestinations {
|
|
const val MAIN_ROUTE = "main"
|
|
const val PDF_VIEWER_ROUTE = "pdf_viewer"
|
|
const val EPUB_READER_ROUTE = "epub_reader"
|
|
const val PRO_SCREEN_ROUTE = "pro_screen"
|
|
const val FEEDBACK_SCREEN_ROUTE = "feedback_screen_route"
|
|
const val FONTS_SCREEN_ROUTE = "fonts_screen_route"
|
|
}
|
|
|
|
@OptIn(ExperimentalMaterial3Api::class)
|
|
@RequiresApi(Build.VERSION_CODES.VANILLA_ICE_CREAM)
|
|
@Composable
|
|
fun AppNavigation(
|
|
navController: NavHostController,
|
|
windowSizeClass: WindowSizeClass,
|
|
viewModel: MainViewModel
|
|
) {
|
|
Timber.d("AppNavigation composable invoked.")
|
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
|
|
|
NavHost(navController = navController, startDestination = AppDestinations.MAIN_ROUTE) {
|
|
composable(AppDestinations.MAIN_ROUTE) {
|
|
Timber.d("Navigating to Main Screen (${AppDestinations.MAIN_ROUTE}).")
|
|
MainScreen(
|
|
viewModel = viewModel,
|
|
windowSizeClass = windowSizeClass,
|
|
navController = navController
|
|
)
|
|
|
|
LaunchedEffect(uiState.selectedFileType, uiState.isLoading, uiState.selectedEpubBook, uiState.selectedPdfUri) {
|
|
if (!uiState.isLoading) {
|
|
when (uiState.selectedFileType) {
|
|
FileType.PDF -> {
|
|
if (uiState.selectedPdfUri != null) {
|
|
Timber.d("Navigating to PDF Viewer. Route: ${AppDestinations.PDF_VIEWER_ROUTE}")
|
|
if (navController.currentDestination?.route != AppDestinations.PDF_VIEWER_ROUTE) {
|
|
navController.navigate(AppDestinations.PDF_VIEWER_ROUTE)
|
|
}
|
|
}
|
|
}
|
|
FileType.EPUB, FileType.MOBI, FileType.MD, FileType.TXT, FileType.HTML -> {
|
|
if (uiState.selectedEpubBook != null) {
|
|
Timber.d("Navigating to EPUB Reader for ${uiState.selectedFileType}. Route: ${AppDestinations.EPUB_READER_ROUTE}")
|
|
if (navController.currentDestination?.route != AppDestinations.EPUB_READER_ROUTE) {
|
|
navController.navigate(AppDestinations.EPUB_READER_ROUTE)
|
|
}
|
|
} else if (uiState.selectedEpubUri != null && uiState.errorMessage == null) {
|
|
Timber.d("${uiState.selectedFileType} selected, waiting for parsing/loading before navigation.")
|
|
} else if (uiState.errorMessage != null) {
|
|
Timber.w("${uiState.selectedFileType} loading failed, staying on Home. Error: ${uiState.errorMessage}")
|
|
}
|
|
}
|
|
null -> {
|
|
if (navController.currentDestination?.route != AppDestinations.MAIN_ROUTE) {
|
|
Timber.d("File cleared, ensuring navigation back to Main Screen.")
|
|
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// PDF Viewer Screen Composable
|
|
composable(route = AppDestinations.PDF_VIEWER_ROUTE) {
|
|
Timber.d("Navigating to PDF Viewer Screen (${AppDestinations.PDF_VIEWER_ROUTE}).")
|
|
val pdfUri = uiState.selectedPdfUri
|
|
val initialPage = uiState.initialPageInBook
|
|
val initialBookmarksJson = uiState.initialBookmarksJson
|
|
|
|
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedPdfUri.toString() }?.bookId
|
|
|
|
if (pdfUri != null) {
|
|
Timber.i("Displaying PDF Viewer for URI: $pdfUri, initialPage: $initialPage")
|
|
PdfViewerScreen(
|
|
pdfUri = pdfUri,
|
|
initialPage = initialPage,
|
|
initialBookmarksJson = initialBookmarksJson,
|
|
isProUser = uiState.isProUser,
|
|
onNavigateBack = {
|
|
Timber.d("Back action triggered from PDF Viewer.")
|
|
viewModel.clearSelectedFile()
|
|
},
|
|
onSavePosition = viewModel::savePdfReadingPosition,
|
|
onBookmarksChanged = { bookmarksJson ->
|
|
if (bookId != null) {
|
|
viewModel.saveBookmarks(bookId, bookmarksJson)
|
|
} else {
|
|
Timber.w("Could not find bookId to save PDF bookmarks for URI: ${uiState.selectedPdfUri}")
|
|
}
|
|
},
|
|
onNavigateToPro = {
|
|
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
|
},
|
|
viewModel = viewModel
|
|
)
|
|
} else {
|
|
Timber.w("PDF URI is null in ViewModel state while on PDF screen. Navigating back to Main.")
|
|
LaunchedEffect(Unit) {
|
|
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// EPUB Reader Screen Composable
|
|
composable(route = AppDestinations.EPUB_READER_ROUTE) {
|
|
Timber.d("Navigating to EPUB Reader Screen (${AppDestinations.EPUB_READER_ROUTE}).")
|
|
val epubBook = uiState.selectedEpubBook
|
|
val isLoading = uiState.isLoading
|
|
val errorMessage = uiState.errorMessage
|
|
val initialLocator = uiState.initialLocator
|
|
val initialCfi = uiState.initialCfi
|
|
val initialBookmarksJson = uiState.initialBookmarksJson
|
|
val renderMode = uiState.renderMode
|
|
|
|
when {
|
|
epubBook != null -> {
|
|
Timber.i("Displaying EPUB Reader for Book: ${epubBook.title}, initialLocator: $initialLocator")
|
|
val coverPath = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.coverImagePath
|
|
val epubUri = uiState.selectedEpubUri
|
|
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId
|
|
val customFonts by viewModel.customFonts.collectAsStateWithLifecycle()
|
|
|
|
EpubReaderScreen(
|
|
epubBook = epubBook,
|
|
renderMode = renderMode,
|
|
initialLocator = initialLocator,
|
|
initialCfi = initialCfi,
|
|
initialBookmarksJson = initialBookmarksJson,
|
|
isProUser = uiState.isProUser,
|
|
coverImagePath = coverPath,
|
|
onNavigateBack = {
|
|
Timber.d("Back action from EPUB Reader. Clearing selected file to navigate home.")
|
|
viewModel.clearSelectedFile()
|
|
},
|
|
onSavePosition = { locator, cfiForWebView, progress ->
|
|
Timber.d("Auto-saving EPUB position: Locator $locator, Progress $progress%")
|
|
epubUri?.let { uri ->
|
|
viewModel.saveEpubReadingPosition(uri, locator, cfiForWebView, progress)
|
|
}
|
|
},
|
|
onBookmarksChanged = { bookmarksJson ->
|
|
val bookId = uiState.recentFiles.find { it.uriString == uiState.selectedEpubUri.toString() }?.bookId
|
|
if (bookId != null) {
|
|
viewModel.saveBookmarks(bookId, bookmarksJson)
|
|
} else {
|
|
Timber.w("Could not find bookId to save bookmarks for URI: ${uiState.selectedEpubUri}")
|
|
}
|
|
},
|
|
onNavigateToPro = {
|
|
navController.navigate(AppDestinations.PRO_SCREEN_ROUTE)
|
|
},
|
|
onRenderModeChange = viewModel::setRenderMode,
|
|
customFonts = customFonts,
|
|
onImportFont = viewModel::importFont,
|
|
)
|
|
}
|
|
isLoading -> {
|
|
Timber.d("EPUB Reader: Showing loading indicator.")
|
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
CircularProgressIndicator()
|
|
}
|
|
}
|
|
errorMessage != null -> {
|
|
Timber.e("EPUB Reader: Showing error message - $errorMessage")
|
|
Column(
|
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
|
horizontalAlignment = Alignment.CenterHorizontally,
|
|
verticalArrangement = Arrangement.Center
|
|
) {
|
|
Text("Error: $errorMessage", color = MaterialTheme.colorScheme.error)
|
|
Spacer(modifier = Modifier.height(16.dp))
|
|
Button(onClick = {
|
|
viewModel.clearSelectedFile()
|
|
}) {
|
|
Text("Go Back")
|
|
}
|
|
}
|
|
}
|
|
else -> {
|
|
Timber.w("EPUB Book is null and not loading/error state on EPUB screen. Navigating back.")
|
|
LaunchedEffect(Unit) {
|
|
navController.popBackStack(AppDestinations.MAIN_ROUTE, inclusive = false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
composable(route = AppDestinations.PRO_SCREEN_ROUTE) {
|
|
ProScreen(
|
|
viewModel = viewModel,
|
|
onNavigateBack = { navController.popBackStack() }
|
|
)
|
|
}
|
|
|
|
composable(route = AppDestinations.FEEDBACK_SCREEN_ROUTE) {
|
|
FeedbackScreen(
|
|
navController = navController
|
|
)
|
|
}
|
|
|
|
composable(route = AppDestinations.FONTS_SCREEN_ROUTE) {
|
|
FontsScreen(
|
|
viewModel = viewModel,
|
|
onBackClick = { navController.popBackStack() }
|
|
)
|
|
}
|
|
}
|
|
} |