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

@ -17,6 +17,7 @@
*
* mail: epistemereader@gmail.com
*/
// HomeScreen
@file:Suppress("DEPRECATION")
package com.aryan.reader
@ -53,6 +54,8 @@ import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.MoreVert
@ -83,6 +86,7 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberDrawerState
import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
@ -253,6 +257,7 @@ fun HomeScreen(
}
},
navController = navController,
onFolderSyncToggle = viewModel::setFolderSyncEnabled
)
}) {
Scaffold(
@ -271,7 +276,8 @@ fun HomeScreen(
drawerState.open()
}
},
onShowDeviceManagement = viewModel::showDeviceManagementForDebug
onShowDeviceManagement = viewModel::showDeviceManagementForDebug,
onFolderSyncToggle = viewModel::setFolderSyncEnabled
)
} else {
ContextualTopAppBar(
@ -291,14 +297,16 @@ fun HomeScreen(
if (uiState.recentFiles.isEmpty()) {
EmptyState(
title = "Your Library is Empty",
message = "Select a PDF, EPUB, MOBI, or AZW3 file from your device to get started.",
message = "Select a file to read, or sync a local folder to automatically import books.",
onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
secondaryButtonText = "Setup Folder Sync",
onSecondaryClick = { viewModel.navigateToFolderSync() }
)
} else {
EmptyState(
title = "No Recent Files",
message = "Open a file from your library to see it here, or select a new file to add.",
message = "Open a file from your library to see it here.",
onSelectFileClick = onSelectFileClick,
modifier = Modifier.weight(1f)
)
@ -310,8 +318,13 @@ fun HomeScreen(
onItemClick = { item -> viewModel.onRecentFileClicked(item) },
onItemLongClick = { item -> viewModel.onRecentItemLongPress(item) },
onSelectFileClick = onSelectFileClick,
onNavigateToFolderSync = { viewModel.navigateToFolderSync() },
windowSizeClass = windowSizeClass,
downloadingBookIds = uiState.downloadingBookIds
downloadingBookIds = uiState.downloadingBookIds,
onRefresh = { viewModel.refreshLibrary() },
isRefreshing = uiState.isRefreshing,
isSyncEnabled = uiState.isSyncEnabled,
hasSyncedFolder = uiState.syncedFolderUri != null
)
}
}
@ -389,9 +402,15 @@ fun HomeScreen(
)
}
}
if (uiState.showFolderMigrationDialog) {
FolderMigrationDialog(
onConfirm = { viewModel.completeFolderMigration() }
)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun RecentFilesContent(
recentFiles: List<RecentFileItem>,
@ -399,30 +418,60 @@ private fun RecentFilesContent(
onItemClick: (RecentFileItem) -> Unit,
onItemLongClick: (RecentFileItem) -> Unit,
onSelectFileClick: () -> Unit,
onNavigateToFolderSync: () -> Unit,
windowSizeClass: WindowSizeClass,
downloadingBookIds: Set<String>,
onRefresh: () -> Unit,
isRefreshing: Boolean,
isSyncEnabled: Boolean,
hasSyncedFolder: Boolean
) {
Box(modifier = Modifier.fillMaxSize()) {
RecentFilesGrid(
modifier = Modifier.padding(horizontal = 16.dp),
recentFiles = recentFiles,
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
onItemClick = onItemClick,
onItemLongClick = onItemLongClick,
windowSizeClass = windowSizeClass,
contentPadding = PaddingValues(top = 8.dp, bottom = 88.dp),
downloadingBookIds = downloadingBookIds
)
val canRefresh = isSyncEnabled || hasSyncedFolder
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(vertical = 24.dp), contentAlignment = Alignment.Center
) {
SelectFileButton(onClick = onSelectFileClick, text = "Select Another File")
val content = @Composable {
Box(modifier = Modifier.fillMaxSize()) {
RecentFilesGrid(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
recentFiles = recentFiles,
selectedItemUris = selectedContextItems.mapNotNull { it.uriString }.toSet(),
onItemClick = onItemClick,
onItemLongClick = onItemLongClick,
windowSizeClass = windowSizeClass,
contentPadding = PaddingValues(top = 8.dp, bottom = 100.dp),
downloadingBookIds = downloadingBookIds
)
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.padding(bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically
) {
androidx.compose.material3.Button(onClick = onSelectFileClick) {
Text("Select File")
}
androidx.compose.material3.OutlinedButton(onClick = onNavigateToFolderSync) {
Text("Sync Folder")
}
}
}
}
if (canRefresh) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = onRefresh,
modifier = Modifier.fillMaxSize()
) {
content()
}
} else {
content()
}
}
@Composable
@ -509,6 +558,26 @@ fun RecentFileCard(
.fillMaxWidth(),
)
if (item.sourceFolderUri != null) {
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(8.dp)
.background(
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.9f),
shape = CircleShape
)
.padding(4.dp)
) {
Icon(
imageVector = Icons.Default.Folder,
contentDescription = "Local Folder",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
if (!item.isAvailable) {
Box(
modifier = Modifier
@ -576,7 +645,8 @@ fun DefaultTopAppBar(
onClearCloudData: () -> Unit,
onDrawerClick: () -> Unit,
onAboutClick: () -> Unit,
onShowDeviceManagement: () -> Unit
onShowDeviceManagement: () -> Unit,
onFolderSyncToggle: (Boolean) -> Unit
) {
var showOptionsMenu by remember { mutableStateOf(false) }
@ -636,7 +706,8 @@ private fun AppDrawerContent(
onUpgradeClick: () -> Unit,
onSyncUpsellClick: () -> Unit,
onFontsClick: () -> Unit,
navController: NavHostController
navController: NavHostController,
onFolderSyncToggle: (Boolean) -> Unit
) {
val isOss = BuildConfig.FLAVOR == "oss"
@ -682,10 +753,10 @@ private fun AppDrawerContent(
Spacer(modifier = Modifier.height(8.dp))
NavigationDrawerItem(
icon = {
Icon(
Icons.Outlined.AccountCircle, contentDescription = "Sign In"
)
},
Icon(
Icons.Outlined.AccountCircle, contentDescription = "Sign In"
)
},
label = { Text("Sign in with Google") },
selected = false,
onClick = onSignInClick,
@ -705,10 +776,10 @@ private fun AppDrawerContent(
NavigationDrawerItem(
icon = {
Icon(
Icons.Default.VerifiedUser, contentDescription = "Episteme Pro"
)
},
Icon(
Icons.Default.VerifiedUser, contentDescription = "Episteme Pro"
)
},
label = {
val text =
if (uiState.isProUser) "Episteme Pro" else "Upgrade to Episteme Pro"
@ -723,34 +794,63 @@ private fun AppDrawerContent(
if (uiState.currentUser != null) {
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.sync),
contentDescription = "Sync Library"
)
}, label = { Text("Sync Library") }, badge = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!uiState.isProUser) {
Icon(
imageVector = Icons.Default.VerifiedUser,
contentDescription = "Pro Feature",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
}
Switch(
checked = uiState.isSyncEnabled, onCheckedChange = {
if (uiState.isProUser) onSyncToggle(it) else onSyncUpsellClick()
}, enabled = uiState.isProUser
Icon(
painter = painterResource(id = R.drawable.sync),
contentDescription = "Sync Library"
)
}
}, selected = false, onClick = {
if (uiState.isProUser) {
onSyncToggle(!uiState.isSyncEnabled)
} else {
onSyncUpsellClick()
}
}, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
}, label = { Text("Sync Library") }, badge = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!uiState.isProUser) {
Icon(
imageVector = Icons.Default.VerifiedUser,
contentDescription = "Pro Feature",
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
}
Switch(
checked = uiState.isSyncEnabled, onCheckedChange = {
if (uiState.isProUser) onSyncToggle(it) else onSyncUpsellClick()
}, enabled = uiState.isProUser
)
}
}, selected = false, onClick = {
if (uiState.isProUser) {
onSyncToggle(!uiState.isSyncEnabled)
} else {
onSyncUpsellClick()
}
}, modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
if (uiState.currentUser != null && uiState.isSyncEnabled) {
NavigationDrawerItem(
icon = {
Icon(
imageVector = Icons.Default.FolderSpecial,
contentDescription = "Backup Local Folders"
)
},
label = {
Column {
Text("Cloud sync for Local Folders")
Text(
"Upload books from your synced folders to Google Drive).",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
badge = {
Switch(
checked = uiState.isFolderSyncEnabled,
onCheckedChange = { onFolderSyncToggle(it) }
)
},
selected = false,
onClick = { onFolderSyncToggle(!uiState.isFolderSyncEnabled) },
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)
)
}
} else {
@ -774,11 +874,11 @@ private fun AppDrawerContent(
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.fonts),
contentDescription = "Custom Fonts"
)
},
Icon(
painter = painterResource(id = R.drawable.fonts),
contentDescription = "Custom Fonts"
)
},
label = { Text("Custom Fonts") },
selected = false,
onClick = onFontsClick,
@ -788,11 +888,11 @@ private fun AppDrawerContent(
if (!isOss) {
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.feedback),
contentDescription = "Feedback"
)
},
Icon(
painter = painterResource(id = R.drawable.feedback),
contentDescription = "Feedback"
)
},
label = { Text("Help & Feedback") },
badge = {
if (uiState.hasUnreadFeedback) {
@ -807,11 +907,11 @@ private fun AppDrawerContent(
if (uiState.currentUser != null) {
NavigationDrawerItem(
icon = {
Icon(
painter = painterResource(id = R.drawable.logout),
contentDescription = "Sign Out"
)
},
Icon(
painter = painterResource(id = R.drawable.logout),
contentDescription = "Sign Out"
)
},
label = { Text("Sign Out") },
selected = false,
onClick = onSignOutClick,
@ -1035,4 +1135,31 @@ fun FpsMonitor(modifier: Modifier = Modifier) {
.background(Color.Black.copy(alpha = 0.5f))
.padding(4.dp)
)
}
@Composable
private fun FolderMigrationDialog(onConfirm: () -> Unit) {
AlertDialog(
onDismissRequest = { }, // Force acknowledgment
icon = { Icon(Icons.Default.FolderSpecial, contentDescription = null) },
title = { Text("Folder Sync Refactored") },
text = {
Column {
Text(
"We've completely rebuilt how Folder Sync works! Books from the folder are now read directly from your folder instead of being copied to app storage."
)
Spacer(modifier = Modifier.height(12.dp))
Text(
"We'll now perform a one-time scan to migrate your existing progress and bookmarks. This will also free up internal storage space on your device.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
confirmButton = {
TextButton(onClick = onConfirm) {
Text("Start Migration")
}
}
)
}