Expanded folder sync functionality to support multiple folders and improved performance. (#47)
- Added `SyncedFolder` data class and transitioned from a single `syncedFolderUri` to a list of `syncedFolders`. - Implemented logic to add, remove, and persist multiple synced folders using JSON in shared preferences, including a migration path for legacy single-folder settings. - Updated `FolderSyncWorker` to iterate through all registered folders and added an annotation sidecar preloading optimization to reduce file I/O during sync. - Redesigned the "Folder Sync" UI in `LibraryScreen` to display a list of active folders with individual management options and a 3-folder limit. - Refined permission handling to request both read and write access for synced folders.
This commit is contained in:
parent
76780bff31
commit
6ea414c9b2
6 changed files with 426 additions and 298 deletions
|
|
@ -55,28 +55,57 @@ class FolderSyncWorker(
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
|
val isMetadataOnly = inputData.getBoolean(KEY_METADATA_ONLY, false)
|
||||||
// Check if folder is still linked before starting
|
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) {
|
|
||||||
Timber.tag("FolderSync").w("Worker: Folder unlinked. Aborting work.")
|
val jsonString = prefs.getString("synced_folders_list_json", null)
|
||||||
|
val folders = mutableListOf<String>()
|
||||||
|
|
||||||
|
if (jsonString != null) {
|
||||||
|
try {
|
||||||
|
val array = org.json.JSONArray(jsonString)
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
folders.add(array.getJSONObject(i).getString("uri"))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { Timber.e(e) }
|
||||||
|
} else {
|
||||||
|
val single = prefs.getString("synced_folder_uri", null)
|
||||||
|
if (single != null) folders.add(single)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (folders.isEmpty()) {
|
||||||
|
Timber.tag("FolderSync").w("Worker: No folders linked. Aborting.")
|
||||||
return Result.success()
|
return Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("FolderSync").d("Worker: Request received (MetadataOnly=$isMetadataOnly). Waiting for lock...")
|
Timber.tag("FolderSync").d("Worker: processing ${folders.size} folders.")
|
||||||
|
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
syncMutex.withLock {
|
syncMutex.withLock {
|
||||||
Timber.tag("FolderSync").d("Worker: Lock acquired. Starting Sync.")
|
var allSuccess = true
|
||||||
performSync(isMetadataOnly)
|
|
||||||
|
for (uriString in folders) {
|
||||||
|
val success = performSyncForFolder(uriString, isMetadataOnly)
|
||||||
|
if (!success) allSuccess = false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonString != null) {
|
||||||
|
try {
|
||||||
|
val array = org.json.JSONArray(jsonString)
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
for (i in 0 until array.length()) {
|
||||||
|
array.getJSONObject(i).put("lastScanTime", now)
|
||||||
|
}
|
||||||
|
prefs.edit { putString("synced_folders_list_json", array.toString()) }
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allSuccess) Result.success() else Result.failure()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun performSync(metadataOnly: Boolean): Result {
|
private suspend fun performSyncForFolder(folderUriString: String, metadataOnly: Boolean): Boolean {
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
if (folderUriString.isBlank()) return true
|
||||||
val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null)
|
|
||||||
|
|
||||||
if (folderUriString.isNullOrBlank()) return Result.success()
|
|
||||||
val folderUri = folderUriString.toUri()
|
val folderUri = folderUriString.toUri()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -86,17 +115,20 @@ class FolderSyncWorker(
|
||||||
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
)
|
)
|
||||||
} catch (_: SecurityException) {
|
} catch (_: SecurityException) {
|
||||||
return Result.failure()
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
|
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
|
||||||
if (documentTree == null || !documentTree.isDirectory) {
|
if (documentTree == null || !documentTree.isDirectory) {
|
||||||
return Result.failure()
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
|
Timber.tag("FolderSync").d("Phase 1: Importing JSON metadata from folder...")
|
||||||
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
|
val folderMetadataMap = LocalSyncUtils.getAllFolderMetadata(appContext, folderUri)
|
||||||
|
|
||||||
|
Timber.tag("FolderSync").d("Phase 1.5: Preloading annotation sidecars...")
|
||||||
|
val preloadedSidecars = LocalSyncUtils.preloadAnnotationSidecars(appContext, documentTree)
|
||||||
|
|
||||||
folderMetadataMap.forEach { (bookId, remoteMeta) ->
|
folderMetadataMap.forEach { (bookId, remoteMeta) ->
|
||||||
val existingItem = recentFilesRepository.getFileByBookId(bookId)
|
val existingItem = recentFilesRepository.getFileByBookId(bookId)
|
||||||
|
|
||||||
|
|
@ -120,19 +152,18 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase 1.5: Sync Annotations for existing books (Runs in both Metadata-Only and Full modes)
|
|
||||||
Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...")
|
Timber.tag("FolderAnnotationSync").d("Phase 1.5: Checking annotation sidecars for existing local books...")
|
||||||
val processedBookIds = mutableSetOf<String>()
|
val processedBookIds = mutableSetOf<String>()
|
||||||
val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
val existingFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
|
|
||||||
for (book in existingFolderBooks) {
|
for (book in existingFolderBooks) {
|
||||||
processedBookIds.add(book.bookId)
|
processedBookIds.add(book.bookId)
|
||||||
val sidecarData = LocalSyncUtils.getAnnotationSidecar(appContext, folderUri, book.bookId)
|
|
||||||
|
val sidecarData = preloadedSidecars[book.bookId]
|
||||||
|
|
||||||
if (sidecarData != null) {
|
if (sidecarData != null) {
|
||||||
val (remoteTs, jsonPayload) = sidecarData
|
val (remoteTs, jsonPayload) = sidecarData
|
||||||
|
|
||||||
// Check timestamps of ALL potential local annotation files
|
|
||||||
val localFiles = listOf(
|
val localFiles = listOf(
|
||||||
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
|
File(appContext.filesDir, "annotations/annotation_${book.bookId}.json"),
|
||||||
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
|
File(appContext.filesDir, "pdf_rich_text/text_${book.bookId}.json"),
|
||||||
|
|
@ -141,7 +172,7 @@ class FolderSyncWorker(
|
||||||
)
|
)
|
||||||
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
|
val localTs = localFiles.maxOfOrNull { if (it.exists()) it.lastModified() else 0L } ?: 0L
|
||||||
|
|
||||||
if (remoteTs > (localTs + 1000)) { // 1s buffer
|
if (remoteTs > (localTs + 1000)) {
|
||||||
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
|
Timber.tag("FolderAnnotationSync").i(">>> Newer sidecar found for ${book.displayName}. Importing.")
|
||||||
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
|
recentFilesRepository.importAnnotationBundle(book.bookId, jsonPayload)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -217,7 +248,7 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!processedBookIds.contains(stableId)) {
|
if (!processedBookIds.contains(stableId)) {
|
||||||
val sidecarData = LocalSyncUtils.getAnnotationSidecar(appContext, folderUri, stableId)
|
val sidecarData = preloadedSidecars[stableId]
|
||||||
|
|
||||||
if (sidecarData != null) {
|
if (sidecarData != null) {
|
||||||
val (remoteTs, jsonPayload) = sidecarData
|
val (remoteTs, jsonPayload) = sidecarData
|
||||||
|
|
@ -238,7 +269,6 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cleanup removed files
|
|
||||||
if (!isStopped) {
|
if (!isStopped) {
|
||||||
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
val dbFolderBooks = recentFilesRepository.getFilesBySourceFolder(folderUriString)
|
||||||
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
|
val idsToRemove = dbFolderBooks.filter { !foundBookIds.contains(it.bookId) }.map { it.bookId }
|
||||||
|
|
@ -250,8 +280,6 @@ class FolderSyncWorker(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
prefs.edit { putLong(MainViewModel.KEY_LAST_FOLDER_SCAN_TIME, System.currentTimeMillis()) }
|
|
||||||
|
|
||||||
if (!isStopped) {
|
if (!isStopped) {
|
||||||
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
|
Timber.tag("FolderSync").i("Folder scan complete. Enqueuing metadata extraction.")
|
||||||
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
|
val metaRequest = OneTimeWorkRequestBuilder<MetadataExtractionWorker>().build()
|
||||||
|
|
@ -262,11 +290,11 @@ class FolderSyncWorker(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.success()
|
return true
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
|
Timber.tag("FolderSync").e(e, "Error during folder sync worker execution.")
|
||||||
return Result.failure()
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -345,16 +345,15 @@ fun HomeScreen(
|
||||||
onRefresh = { viewModel.refreshLibrary() },
|
onRefresh = { viewModel.refreshLibrary() },
|
||||||
isRefreshing = uiState.isRefreshing,
|
isRefreshing = uiState.isRefreshing,
|
||||||
isSyncEnabled = uiState.isSyncEnabled,
|
isSyncEnabled = uiState.isSyncEnabled,
|
||||||
hasSyncedFolder = uiState.syncedFolderUri != null
|
hasSyncedFolder = uiState.syncedFolders.isNotEmpty()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading Indicator Overlay
|
|
||||||
if (uiState.isLoading) {
|
if (uiState.isLoading) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f) // Semi-transparent overlay
|
color = MaterialTheme.colorScheme.background.copy(alpha = 0.7f)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|
|
||||||
|
|
@ -60,7 +60,6 @@ import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.ButtonDefaults
|
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.DropdownMenu
|
import androidx.compose.material3.DropdownMenu
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
|
|
@ -154,7 +153,7 @@ fun LibraryScreen(
|
||||||
contract = ActivityResultContracts.OpenDocumentTree()
|
contract = ActivityResultContracts.OpenDocumentTree()
|
||||||
) { uri ->
|
) { uri ->
|
||||||
uri?.let {
|
uri?.let {
|
||||||
viewModel.setSyncedFolder(it)
|
viewModel.addSyncedFolder(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -264,9 +263,11 @@ fun LibraryScreen(
|
||||||
onScanNowClick = viewModel::scanSyncedFolder,
|
onScanNowClick = viewModel::scanSyncedFolder,
|
||||||
onSyncMetadataClick = viewModel::syncFolderMetadata,
|
onSyncMetadataClick = viewModel::syncFolderMetadata,
|
||||||
onSelectSyncFolderClick = onSelectSyncFolderClick,
|
onSelectSyncFolderClick = onSelectSyncFolderClick,
|
||||||
onDisconnectSyncFolderClick = viewModel::disconnectSyncedFolder,
|
syncedFolders = uiState.syncedFolders,
|
||||||
|
onAddFolderClick = { uri -> viewModel.addSyncedFolder(uri) },
|
||||||
|
onRemoveFolderClick = { folder -> viewModel.removeSyncedFolder(folder) },
|
||||||
|
onDisconnectSyncFolderClick = viewModel::disconnectAllSyncedFolders,
|
||||||
downloadingBookIds = uiState.downloadingBookIds,
|
downloadingBookIds = uiState.downloadingBookIds,
|
||||||
syncedFolderUri = uiState.syncedFolderUri,
|
|
||||||
lastFolderScanTime = uiState.lastFolderScanTime,
|
lastFolderScanTime = uiState.lastFolderScanTime,
|
||||||
isLoading = uiState.isLoading
|
isLoading = uiState.isLoading
|
||||||
)
|
)
|
||||||
|
|
@ -461,9 +462,11 @@ fun LibraryScreenContent(
|
||||||
onSelectSyncFolderClick: () -> Unit,
|
onSelectSyncFolderClick: () -> Unit,
|
||||||
onDisconnectSyncFolderClick: () -> Unit,
|
onDisconnectSyncFolderClick: () -> Unit,
|
||||||
downloadingBookIds: Set<String>,
|
downloadingBookIds: Set<String>,
|
||||||
syncedFolderUri: String?,
|
|
||||||
lastFolderScanTime: Long?,
|
lastFolderScanTime: Long?,
|
||||||
isLoading: Boolean,
|
isLoading: Boolean,
|
||||||
|
syncedFolders: List<SyncedFolder>,
|
||||||
|
onAddFolderClick: (android.net.Uri) -> Unit,
|
||||||
|
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||||
) {
|
) {
|
||||||
val isBookContextualModeActive = selectedItems.isNotEmpty()
|
val isBookContextualModeActive = selectedItems.isNotEmpty()
|
||||||
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
|
||||||
|
|
@ -666,13 +669,11 @@ fun LibraryScreenContent(
|
||||||
}
|
}
|
||||||
2 -> {
|
2 -> {
|
||||||
FolderSyncScreen(
|
FolderSyncScreen(
|
||||||
syncedFolderUri = syncedFolderUri,
|
syncedFolders = syncedFolders,
|
||||||
lastScanTime = lastFolderScanTime,
|
onAddFolderClick = onAddFolderClick,
|
||||||
onSelectFolderClick = onSelectSyncFolderClick,
|
onRemoveFolderClick = onRemoveFolderClick,
|
||||||
onScanNowClick = onScanNowClick,
|
onScanNowClick = onScanNowClick,
|
||||||
onSyncMetadataClick = onSyncMetadataClick,
|
onSyncMetadataClick = onSyncMetadataClick,
|
||||||
onChangeFolderClick = onSelectSyncFolderClick,
|
|
||||||
onDisconnectClick = onDisconnectSyncFolderClick,
|
|
||||||
isLoading = isLoading
|
isLoading = isLoading
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1382,182 +1383,43 @@ private fun getDisplayPathFromUri(context: Context, uriString: String): String {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FolderSyncScreen(
|
private fun FolderSyncScreen(
|
||||||
syncedFolderUri: String?,
|
syncedFolders: List<SyncedFolder>,
|
||||||
lastScanTime: Long?,
|
onAddFolderClick: (android.net.Uri) -> Unit,
|
||||||
onSelectFolderClick: () -> Unit,
|
onRemoveFolderClick: (SyncedFolder) -> Unit,
|
||||||
onScanNowClick: () -> Unit,
|
onScanNowClick: () -> Unit,
|
||||||
onSyncMetadataClick: () -> Unit,
|
onSyncMetadataClick: () -> Unit,
|
||||||
onChangeFolderClick: () -> Unit,
|
|
||||||
onDisconnectClick: () -> Unit,
|
|
||||||
isLoading: Boolean
|
isLoading: Boolean
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val pickFolderLauncher = rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.OpenDocumentTree()
|
||||||
|
) { uri ->
|
||||||
|
uri?.let {
|
||||||
|
onAddFolderClick(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (syncedFolderUri == null) {
|
LocalContext.current
|
||||||
EmptyState(
|
|
||||||
title = "Sync Local Folder",
|
Scaffold(
|
||||||
message = "Connect a folder to create a live library. Episteme will automatically monitor your files for new additions and keep your reading progress and other book metadata in sync with your folder.",
|
floatingActionButton = {
|
||||||
onSelectFileClick = onSelectFolderClick,
|
if (syncedFolders.size < 3) {
|
||||||
primaryButtonText = "Select Folder",
|
ExtendedFloatingActionButton(
|
||||||
modifier = Modifier.fillMaxSize()
|
text = { Text("Add Folder") },
|
||||||
|
icon = { Icon(Icons.Default.Add, "Add") },
|
||||||
|
onClick = { pickFolderLauncher.launch(null) }
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
val folderPath = remember(syncedFolderUri) {
|
|
||||||
getDisplayPathFromUri(context, syncedFolderUri)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate times
|
|
||||||
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
|
|
||||||
|
|
||||||
val lastScanText = remember(lastScanTime) {
|
|
||||||
if (lastScanTime == null || lastScanTime == 0L) "Never"
|
|
||||||
else dateFormat.format(Date(lastScanTime))
|
|
||||||
}
|
|
||||||
|
|
||||||
val nextScanText = remember(lastScanTime) {
|
|
||||||
if (lastScanTime == null || lastScanTime == 0L) "Pending first scan..."
|
|
||||||
else {
|
|
||||||
// Adding 4 hours (4 * 60 * 60 * 1000) to match the Worker interval
|
|
||||||
val nextTime = lastScanTime + (4 * 60 * 60 * 1000)
|
|
||||||
dateFormat.format(Date(nextTime))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
) { padding ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp)
|
|
||||||
) {
|
|
||||||
// 1. Status Dashboard Card
|
|
||||||
androidx.compose.material3.ElevatedCard(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(20.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// Header Row with Status
|
// Global Actions Header
|
||||||
Row(
|
if (syncedFolders.isNotEmpty()) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.FolderSpecial,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Text(
|
|
||||||
text = "Active Sync",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Status Indicator
|
|
||||||
Surface(
|
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest,
|
|
||||||
shape = androidx.compose.foundation.shape.CircleShape
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
if (isLoading) {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.size(12.dp),
|
|
||||||
strokeWidth = 2.dp
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
"Scanning...",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(8.dp)
|
|
||||||
.background(
|
|
||||||
Color(0xFF4CAF50), // Green for active
|
|
||||||
androidx.compose.foundation.shape.CircleShape
|
|
||||||
)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
|
||||||
Text(
|
|
||||||
"Monitoring",
|
|
||||||
style = MaterialTheme.typography.labelSmall
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
|
||||||
|
|
||||||
// Folder Path
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "LOCATION",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = folderPath,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
maxLines = 2,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Times Grid
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "LAST CHECK",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "NEXT AUTO SYNC",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(2.dp))
|
|
||||||
Text(text = nextScanText, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Main Actions
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
Text(
|
|
||||||
text = "Actions",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.padding(start = 4.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
|
@ -1568,13 +1430,13 @@ private fun FolderSyncScreen(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
) {
|
) {
|
||||||
Icon(
|
if (isLoading) {
|
||||||
imageVector = Icons.Default.Search,
|
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
|
||||||
contentDescription = null,
|
} else {
|
||||||
modifier = Modifier.size(18.dp)
|
Icon(Icons.Default.Search, null, modifier = Modifier.size(18.dp))
|
||||||
)
|
}
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Scan Files")
|
Text(if (isLoading) "Scanning..." else "Scan All")
|
||||||
}
|
}
|
||||||
|
|
||||||
androidx.compose.material3.OutlinedButton(
|
androidx.compose.material3.OutlinedButton(
|
||||||
|
|
@ -1583,45 +1445,121 @@ private fun FolderSyncScreen(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
shape = MaterialTheme.shapes.small
|
shape = MaterialTheme.shapes.small
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(painterResource(id = R.drawable.sync), null, modifier = Modifier.size(18.dp))
|
||||||
painter = painterResource(id = R.drawable.sync),
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Sync Data")
|
Text("Sync Meta")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
EmptyState(
|
||||||
|
title = "Sync Local Folders",
|
||||||
|
message = "Connect local folders to create a live library. Episteme will monitor files and sync progress.",
|
||||||
|
onSelectFileClick = { pickFolderLauncher.launch(null) },
|
||||||
|
primaryButtonText = "Select Folder",
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List of Folders
|
||||||
|
LazyColumn(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
contentPadding = PaddingValues(bottom = 80.dp) // Space for FAB
|
||||||
|
) {
|
||||||
|
items(syncedFolders, key = { it.uriString }) { folder ->
|
||||||
|
FolderCard(folder, onRemoveFolderClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
@Composable
|
||||||
|
private fun FolderCard(
|
||||||
Column {
|
folder: SyncedFolder,
|
||||||
HorizontalDivider(modifier = Modifier.padding(bottom = 16.dp))
|
onRemoveClick: (SyncedFolder) -> Unit
|
||||||
|
) {
|
||||||
|
var showMenu by remember { mutableStateOf(false) }
|
||||||
|
val dateFormat = remember { SimpleDateFormat("MMM d, h:mm a", Locale.getDefault()) }
|
||||||
|
val lastScanText = if (folder.lastScanTime == 0L) "Never" else dateFormat.format(Date(folder.lastScanTime))
|
||||||
|
|
||||||
|
androidx.compose.material3.ElevatedCard(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
colors = androidx.compose.material3.CardDefaults.elevatedCardColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
TextButton(onClick = onChangeFolderClick, enabled = !isLoading) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
|
||||||
Text("Change Folder")
|
Icon(
|
||||||
|
imageVector = Icons.Default.FolderSpecial,
|
||||||
|
contentDescription = null,
|
||||||
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
TextButton(
|
Box {
|
||||||
onClick = onDisconnectClick,
|
IconButton(onClick = { showMenu = true }) {
|
||||||
enabled = !isLoading,
|
Icon(Icons.Default.MoreVert, "Options")
|
||||||
colors = ButtonDefaults.textButtonColors(
|
}
|
||||||
contentColor = MaterialTheme.colorScheme.error
|
DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Remove Folder") },
|
||||||
|
onClick = {
|
||||||
|
showMenu = false
|
||||||
|
onRemoveClick(folder)
|
||||||
|
},
|
||||||
|
colors = androidx.compose.material3.MenuDefaults.itemColors(
|
||||||
|
textColor = MaterialTheme.colorScheme.error
|
||||||
)
|
)
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
}
|
||||||
Text("Disconnect")
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
|
||||||
|
|
||||||
|
// Details
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = "LAST SYNC",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Text(text = lastScanText, style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
|
||||||
|
// You could add Book Count here if we queried it from DB
|
||||||
|
// For now, let's just show Status
|
||||||
|
Column(modifier = Modifier.weight(1f), horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = "STATUS",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(6.dp)
|
||||||
|
.background(Color(0xFF4CAF50), androidx.compose.foundation.shape.CircleShape)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(text = "Active", style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ import android.content.SharedPreferences
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.provider.DocumentsContract
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
|
@ -133,6 +134,12 @@ data class DeviceLimitReachedState(
|
||||||
val isLimitReached: Boolean = false, val registeredDevices: List<DeviceItem> = emptyList()
|
val isLimitReached: Boolean = false, val registeredDevices: List<DeviceItem> = emptyList()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data class SyncedFolder(
|
||||||
|
val uriString: String,
|
||||||
|
val name: String,
|
||||||
|
val lastScanTime: Long
|
||||||
|
)
|
||||||
|
|
||||||
data class Shelf(val name: String, val books: List<RecentFileItem>) {
|
data class Shelf(val name: String, val books: List<RecentFileItem>) {
|
||||||
val bookCount: Int
|
val bookCount: Int
|
||||||
get() = books.size
|
get() = books.size
|
||||||
|
|
@ -185,7 +192,7 @@ data class ReaderScreenState(
|
||||||
val isRequestingDrivePermission: Boolean = false,
|
val isRequestingDrivePermission: Boolean = false,
|
||||||
val downloadingBookIds: Set<String> = emptySet(),
|
val downloadingBookIds: Set<String> = emptySet(),
|
||||||
val uploadingBookIds: Set<String> = emptySet(),
|
val uploadingBookIds: Set<String> = emptySet(),
|
||||||
val syncedFolderUri: String? = null,
|
val syncedFolders: List<SyncedFolder> = emptyList(),
|
||||||
val lastFolderScanTime: Long? = null,
|
val lastFolderScanTime: Long? = null,
|
||||||
val hasUnreadFeedback: Boolean = false,
|
val hasUnreadFeedback: Boolean = false,
|
||||||
val searchQuery: String = "",
|
val searchQuery: String = "",
|
||||||
|
|
@ -269,7 +276,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
currentUser = authRepository.getSignedInUser(),
|
currentUser = authRepository.getSignedInUser(),
|
||||||
isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false),
|
isSyncEnabled = prefs.getBoolean(KEY_SYNC_ENABLED, false),
|
||||||
isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false),
|
isFolderSyncEnabled = prefs.getBoolean(KEY_FOLDER_SYNC_ENABLED, false),
|
||||||
syncedFolderUri = prefs.getString(KEY_SYNCED_FOLDER_URI, null),
|
syncedFolders = loadSyncedFoldersFromPrefs(),
|
||||||
lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong(
|
lastFolderScanTime = if (prefs.contains(KEY_LAST_FOLDER_SCAN_TIME)) prefs.getLong(
|
||||||
KEY_LAST_FOLDER_SCAN_TIME,
|
KEY_LAST_FOLDER_SCAN_TIME,
|
||||||
0L
|
0L
|
||||||
|
|
@ -453,7 +460,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
): PageModificationResult = withContext(Dispatchers.Default) {
|
): PageModificationResult = withContext(Dispatchers.Default) {
|
||||||
Timber.d("Removing page at index $removeIndex for book $bookId")
|
Timber.d("Removing page at index $removeIndex for book $bookId")
|
||||||
|
|
||||||
// 1. Update Layout
|
|
||||||
val newLayout = currentLayout.toMutableList()
|
val newLayout = currentLayout.toMutableList()
|
||||||
if (removeIndex in newLayout.indices) {
|
if (removeIndex in newLayout.indices) {
|
||||||
newLayout.removeAt(removeIndex)
|
newLayout.removeAt(removeIndex)
|
||||||
|
|
@ -463,7 +469,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Shift Annotations (Delete for removed page, decrement for pages > removed)
|
|
||||||
val newAnnotations = mutableMapOf<Int, List<PdfAnnotation>>()
|
val newAnnotations = mutableMapOf<Int, List<PdfAnnotation>>()
|
||||||
currentAnnotations.forEach { (pageIdx, annots) ->
|
currentAnnotations.forEach { (pageIdx, annots) ->
|
||||||
if (pageIdx != removeIndex) {
|
if (pageIdx != removeIndex) {
|
||||||
|
|
@ -473,7 +478,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Shift Bookmarks
|
|
||||||
val newTotalPages = newLayout.size
|
val newTotalPages = newLayout.size
|
||||||
val newBookmarksJson = try {
|
val newBookmarksJson = try {
|
||||||
if (currentBookmarksJson.isNotBlank()) {
|
if (currentBookmarksJson.isNotBlank()) {
|
||||||
|
|
@ -504,7 +508,6 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
currentBookmarksJson
|
currentBookmarksJson
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Save Layout persistently
|
|
||||||
pageLayoutRepository.saveLayout(bookId, newLayout)
|
pageLayoutRepository.saveLayout(bookId, newLayout)
|
||||||
|
|
||||||
PageModificationResult(newLayout, newAnnotations, newBookmarksJson)
|
PageModificationResult(newLayout, newAnnotations, newBookmarksJson)
|
||||||
|
|
@ -528,9 +531,8 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
val isMigrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false)
|
val isMigrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false)
|
||||||
|
|
||||||
if (_internalState.value.syncedFolderUri != null) {
|
if (_internalState.value.syncedFolders.isNotEmpty()) {
|
||||||
if (isMigrationCompleted) {
|
if (isMigrationCompleted) {
|
||||||
Timber.d("App Start: Triggering local folder metadata-only sync.")
|
|
||||||
syncFolderMetadata()
|
syncFolderMetadata()
|
||||||
} else {
|
} else {
|
||||||
Timber.d("App Start: Skipping sync. Waiting for migration/detachment logic.")
|
Timber.d("App Start: Skipping sync. Waiting for migration/detachment logic.")
|
||||||
|
|
@ -586,10 +588,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
val folderUri = _internalState.value.syncedFolderUri
|
|
||||||
val migrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false)
|
val migrationCompleted = prefs.getBoolean(KEY_FOLDER_MIGRATION_COMPLETED, false)
|
||||||
|
|
||||||
if (folderUri != null && !migrationCompleted) {
|
val hasFolders = _internalState.value.syncedFolders.isNotEmpty()
|
||||||
|
if (hasFolders && !migrationCompleted) {
|
||||||
Timber.tag("FolderSync").d("First time after refactor: Showing migration dialog.")
|
Timber.tag("FolderSync").d("First time after refactor: Showing migration dialog.")
|
||||||
_internalState.update { it.copy(showFolderMigrationDialog = true) }
|
_internalState.update { it.copy(showFolderMigrationDialog = true) }
|
||||||
}
|
}
|
||||||
|
|
@ -608,6 +610,19 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun getDisplayPathFromUri(context: Context, uriString: String): String {
|
||||||
|
val uri = uriString.toUri()
|
||||||
|
val fallbackName = DocumentFile.fromTreeUri(context, uri)?.name ?: "Unknown Folder"
|
||||||
|
if (DocumentsContract.isTreeUri(uri) && DocumentsContract.getTreeDocumentId(uri).isNotEmpty()) {
|
||||||
|
val documentId = DocumentsContract.getTreeDocumentId(uri)
|
||||||
|
val split = documentId.split(":")
|
||||||
|
if (split.size > 1) {
|
||||||
|
return split[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fallbackName
|
||||||
|
}
|
||||||
|
|
||||||
private val fontsRepository = FontsRepository(appContext)
|
private val fontsRepository = FontsRepository(appContext)
|
||||||
|
|
||||||
val customFonts = fontsRepository.getAllFonts().stateIn(
|
val customFonts = fontsRepository.getAllFonts().stateIn(
|
||||||
|
|
@ -1268,20 +1283,83 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setSyncedFolder(folderUri: Uri) {
|
private fun loadSyncedFoldersFromPrefs(): List<SyncedFolder> {
|
||||||
|
val jsonString = prefs.getString(KEY_SYNCED_FOLDERS_JSON, null)
|
||||||
|
val folders = mutableListOf<SyncedFolder>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
folders.add(
|
||||||
|
SyncedFolder(
|
||||||
|
uriString = obj.getString("uri"),
|
||||||
|
name = obj.getString("name"),
|
||||||
|
lastScanTime = obj.optLong("lastScanTime", 0L)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.e(e, "Failed to parse synced folders JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return folders
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveSyncedFoldersToPrefs(folders: List<SyncedFolder>) {
|
||||||
|
val jsonArray = JSONArray()
|
||||||
|
folders.forEach { folder ->
|
||||||
|
val obj = JSONObject()
|
||||||
|
obj.put("uri", folder.uriString)
|
||||||
|
obj.put("name", folder.name)
|
||||||
|
obj.put("lastScanTime", folder.lastScanTime)
|
||||||
|
jsonArray.put(obj)
|
||||||
|
}
|
||||||
|
prefs.edit { putString(KEY_SYNCED_FOLDERS_JSON, jsonArray.toString()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addSyncedFolder(folderUri: Uri) {
|
||||||
|
val currentFolders = _internalState.value.syncedFolders
|
||||||
|
|
||||||
|
if (currentFolders.size >= MAX_FOLDER_LIMIT) {
|
||||||
|
showBanner("Limit reached: Maximum $MAX_FOLDER_LIMIT folders allowed.", isError = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentFolders.any { it.uriString == folderUri.toString() }) {
|
||||||
|
showBanner("This folder is already synced.", isError = true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
appContext.contentResolver.takePersistableUriPermission(
|
appContext.contentResolver.takePersistableUriPermission(
|
||||||
folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION
|
folderUri, Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
)
|
)
|
||||||
Timber.d("Persistable URI permission taken for folder: $folderUri")
|
|
||||||
prefs.edit {
|
val name = getDisplayPathFromUri(appContext, folderUri.toString())
|
||||||
putString(KEY_SYNCED_FOLDER_URI, folderUri.toString())
|
val newFolder = SyncedFolder(folderUri.toString(), name, 0L)
|
||||||
putBoolean(KEY_FOLDER_MIGRATION_COMPLETED, true)
|
val newStats = currentFolders + newFolder
|
||||||
}
|
|
||||||
|
saveSyncedFoldersToPrefs(newStats)
|
||||||
|
|
||||||
_internalState.update { it.copy(
|
_internalState.update { it.copy(
|
||||||
syncedFolderUri = folderUri.toString(),
|
syncedFolders = newStats,
|
||||||
showFolderMigrationDialog = false
|
showFolderMigrationDialog = false
|
||||||
) }
|
) }
|
||||||
|
|
||||||
|
|
@ -1289,23 +1367,47 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
val workManager = WorkManager.getInstance(appContext)
|
val workManager = WorkManager.getInstance(appContext)
|
||||||
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
|
val constraints = Constraints.Builder().setRequiresBatteryNotLow(true).build()
|
||||||
|
val syncRequest = PeriodicWorkRequestBuilder<FolderSyncWorker>(4, TimeUnit.HOURS)
|
||||||
val syncRequest =
|
.setConstraints(constraints)
|
||||||
PeriodicWorkRequestBuilder<FolderSyncWorker>(4, TimeUnit.HOURS).setConstraints(
|
.build()
|
||||||
constraints
|
|
||||||
).build()
|
|
||||||
|
|
||||||
workManager.enqueueUniquePeriodicWork(
|
workManager.enqueueUniquePeriodicWork(
|
||||||
FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.REPLACE, syncRequest
|
FolderSyncWorker.WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, syncRequest
|
||||||
)
|
)
|
||||||
Timber.d("Scheduled periodic folder sync worker.")
|
|
||||||
|
showBanner("Folder added: $name")
|
||||||
|
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
Timber.e(e, "Failed to take persistable URI permission for $folderUri")
|
Timber.e(e, "Failed to take permissions for $folderUri")
|
||||||
_internalState.update {
|
showBanner("Failed to access folder permissions.", isError = true)
|
||||||
it.copy(errorMessage = "Could not get permission for the selected folder.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun removeSyncedFolder(folder: SyncedFolder) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val currentFolders = _internalState.value.syncedFolders.toMutableList()
|
||||||
|
currentFolders.removeAll { it.uriString == folder.uriString }
|
||||||
|
|
||||||
|
saveSyncedFoldersToPrefs(currentFolders)
|
||||||
|
_internalState.update { it.copy(syncedFolders = currentFolders) }
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
||||||
|
try {
|
||||||
|
appContext.contentResolver.releasePersistableUriPermission(
|
||||||
|
folder.uriString.toUri(),
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.w("Failed to release permissions: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentFolders.isEmpty()) {
|
||||||
|
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||||
|
}
|
||||||
|
|
||||||
|
showBanner("Folder removed.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun syncFolderMetadata() {
|
fun syncFolderMetadata() {
|
||||||
|
|
@ -1317,8 +1419,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun triggerFolderSyncWorker(metadataOnly: Boolean) {
|
private fun triggerFolderSyncWorker(metadataOnly: Boolean) {
|
||||||
@Suppress("UnusedVariable", "Unused") val folderUriString = _internalState.value.syncedFolderUri ?: return
|
val folders = _internalState.value.syncedFolders
|
||||||
Timber.tag("FolderSync").d("Requesting folder sync (metadataOnly=$metadataOnly)")
|
if (folders.isEmpty()) return
|
||||||
|
|
||||||
|
Timber.tag("FolderSync").d("Requesting folder sync for ${folders.size} folders (metadataOnly=$metadataOnly)")
|
||||||
|
|
||||||
val workManager = WorkManager.getInstance(appContext)
|
val workManager = WorkManager.getInstance(appContext)
|
||||||
val data = androidx.work.Data.Builder()
|
val data = androidx.work.Data.Builder()
|
||||||
|
|
@ -1369,35 +1473,25 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun disconnectSyncedFolder() {
|
fun disconnectAllSyncedFolders() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val folderUriString = _internalState.value.syncedFolderUri
|
val folders = _internalState.value.syncedFolders
|
||||||
|
folders.forEach { folder ->
|
||||||
Timber.tag("FolderSync").d("Cancelling all folder sync workers...")
|
recentFilesRepository.deleteFilesBySourceFolder(folder.uriString)
|
||||||
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
try {
|
||||||
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME_ONETIME)
|
appContext.contentResolver.releasePersistableUriPermission(
|
||||||
WorkManager.getInstance(appContext).cancelUniqueWork(MetadataExtractionWorker.WORK_NAME)
|
folder.uriString.toUri(), Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
prefs.edit {
|
prefs.edit {
|
||||||
|
remove(KEY_SYNCED_FOLDERS_JSON)
|
||||||
remove(KEY_SYNCED_FOLDER_URI)
|
remove(KEY_SYNCED_FOLDER_URI)
|
||||||
remove(KEY_LAST_FOLDER_SCAN_TIME)
|
|
||||||
}
|
}
|
||||||
_internalState.update { it.copy(syncedFolderUri = null, lastFolderScanTime = null) }
|
_internalState.update { it.copy(syncedFolders = emptyList()) }
|
||||||
|
|
||||||
if (folderUriString != null) {
|
WorkManager.getInstance(appContext).cancelUniqueWork(FolderSyncWorker.WORK_NAME)
|
||||||
Timber.tag("FolderSync").d("Disconnecting folder. Removing all associated books from DB.")
|
|
||||||
recentFilesRepository.deleteFilesBySourceFolder(folderUriString)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val uri = folderUriString.toUri()
|
|
||||||
val contentResolver = appContext.contentResolver
|
|
||||||
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION
|
|
||||||
contentResolver.releasePersistableUriPermission(uri, takeFlags)
|
|
||||||
Timber.tag("FolderSync").d("Released permission for: $uri")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.e(e, "Failed to release permission")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2640,11 +2734,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
|
|
||||||
fun refreshLibrary() {
|
fun refreshLibrary() {
|
||||||
val syncEnabled = _internalState.value.isSyncEnabled
|
val syncEnabled = _internalState.value.isSyncEnabled
|
||||||
val hasFolder = _internalState.value.syncedFolderUri != null // Check for URI instead of toggle
|
val hasFolder = _internalState.value.syncedFolders.isNotEmpty()
|
||||||
|
|
||||||
if (!syncEnabled && !hasFolder) {
|
if (!syncEnabled && !hasFolder) {
|
||||||
Timber.d("Refresh skipped: No sync methods active.")
|
Timber.d("Refresh skipped: No sync methods active.")
|
||||||
_internalState.update { it.copy(isRefreshing = false) } // Ensure indicator retracts immediately
|
_internalState.update { it.copy(isRefreshing = false) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2657,14 +2751,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasFolder) {
|
if (hasFolder) {
|
||||||
// This triggers the worker which we observe above to clear isRefreshing
|
|
||||||
syncFolderMetadata()
|
syncFolderMetadata()
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.e(e, "Refresh failed")
|
Timber.e(e, "Refresh failed")
|
||||||
_internalState.update { it.copy(isRefreshing = false) }
|
_internalState.update { it.copy(isRefreshing = false) }
|
||||||
} finally {
|
} finally {
|
||||||
// If folder sync isn't running, we must close the indicator here
|
|
||||||
if (!hasFolder) {
|
if (!hasFolder) {
|
||||||
_internalState.update { it.copy(isRefreshing = false) }
|
_internalState.update { it.copy(isRefreshing = false) }
|
||||||
}
|
}
|
||||||
|
|
@ -3346,8 +3438,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
|
||||||
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
|
private const val KEY_LAST_SYNC_TIMESTAMP = "last_sync_timestamp"
|
||||||
private const val KEY_MIGRATION_CHECKED_UIDS = "migration_checked_uids"
|
private const val KEY_MIGRATION_CHECKED_UIDS = "migration_checked_uids"
|
||||||
private const val KEY_INSTALLATION_ID = "installation_id"
|
private const val KEY_INSTALLATION_ID = "installation_id"
|
||||||
|
private const val KEY_APP_OPEN_COUNT = "app_open_count"
|
||||||
internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri"
|
internal const val KEY_SYNCED_FOLDER_URI = "synced_folder_uri"
|
||||||
internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
|
internal const val KEY_LAST_FOLDER_SCAN_TIME = "last_folder_scan_time"
|
||||||
private const val KEY_APP_OPEN_COUNT = "app_open_count"
|
private const val KEY_SYNCED_FOLDERS_JSON = "synced_folders_list_json"
|
||||||
|
private const val MAX_FOLDER_LIMIT = 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,12 @@ class MetadataExtractionWorker(
|
||||||
|
|
||||||
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
|
||||||
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
|
||||||
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) {
|
|
||||||
Timber.tag("MetadataWorker").w("Folder disconnected. Stopping extraction worker.")
|
val hasLegacy = prefs.contains("synced_folder_uri")
|
||||||
|
val hasNew = prefs.contains("synced_folders_list_json")
|
||||||
|
|
||||||
|
if (!hasLegacy && !hasNew) {
|
||||||
|
Timber.tag("MetadataWorker").w("No folders linked. Stopping.")
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,7 +50,7 @@ class MetadataExtractionWorker(
|
||||||
filesToProcess.forEach { item ->
|
filesToProcess.forEach { item ->
|
||||||
if (isStopped) return@forEach
|
if (isStopped) return@forEach
|
||||||
|
|
||||||
if (!prefs.contains(MainViewModel.KEY_SYNCED_FOLDER_URI)) return@forEach
|
if (item.sourceFolderUri == null) return@forEach
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val uri = item.uriString?.toUri() ?: return@forEach
|
val uri = item.uriString?.toUri() ?: return@forEach
|
||||||
|
|
|
||||||
|
|
@ -184,6 +184,71 @@ object LocalSyncUtils {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
suspend fun preloadAnnotationSidecars(
|
||||||
|
context: Context,
|
||||||
|
rootTree: DocumentFile
|
||||||
|
): Map<String, Pair<Long, String>> = withContext(Dispatchers.IO) {
|
||||||
|
val results = mutableMapOf<String, Pair<Long, String>>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val allFiles = rootTree.listFiles()
|
||||||
|
|
||||||
|
val annotationFiles = allFiles.filter { file ->
|
||||||
|
val name = file.name ?: ""
|
||||||
|
name.contains(ANNOTATION_SUFFIX) && name.endsWith(".json") && !name.endsWith(".tmp")
|
||||||
|
}
|
||||||
|
|
||||||
|
val filesByBookId = annotationFiles.groupBy { file ->
|
||||||
|
val name = file.name ?: ""
|
||||||
|
var temp = name.substringBeforeLast(".json")
|
||||||
|
if (temp.contains(".sync-conflict")) {
|
||||||
|
temp = temp.substringBefore(".sync-conflict")
|
||||||
|
}
|
||||||
|
if (temp.endsWith(ANNOTATION_SUFFIX)) {
|
||||||
|
temp = temp.substring(0, temp.length - ANNOTATION_SUFFIX.length)
|
||||||
|
}
|
||||||
|
if (temp.startsWith(".")) {
|
||||||
|
temp = temp.substring(1)
|
||||||
|
}
|
||||||
|
temp
|
||||||
|
}
|
||||||
|
|
||||||
|
filesByBookId.forEach { (bookId, files) ->
|
||||||
|
if (bookId.isNotBlank()) {
|
||||||
|
var bestTs = -1L
|
||||||
|
var bestData: String? = null
|
||||||
|
|
||||||
|
for (file in files) {
|
||||||
|
try {
|
||||||
|
val content = context.contentResolver.openInputStream(file.uri)?.use {
|
||||||
|
it.bufferedReader().readText()
|
||||||
|
} ?: continue
|
||||||
|
|
||||||
|
val json = JSONObject(content)
|
||||||
|
val ts = json.optLong("timestamp", 0L)
|
||||||
|
val data = json.optJSONObject("data")?.toString()
|
||||||
|
|
||||||
|
if (data != null && ts > bestTs) {
|
||||||
|
bestTs = ts
|
||||||
|
bestData = data
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("FolderAnnotationSync").e(e, "Error parsing preloaded file: ${file.name}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bestData != null) {
|
||||||
|
results[bookId] = Pair(bestTs, bestData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("FolderAnnotationSync").e(e, "Error preloading annotation sidecars")
|
||||||
|
}
|
||||||
|
|
||||||
|
return@withContext results
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getAnnotationSidecar(
|
suspend fun getAnnotationSidecar(
|
||||||
context: Context,
|
context: Context,
|
||||||
sourceFolderUri: Uri,
|
sourceFolderUri: Uri,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue